feat(worktrees): worktree dialog, launch in a worktree, remove on close

The worktree API needs a front door that works from the place cases are
already picked. The toolbar case picker gains a third action row,
"Git worktrees…" (the phone case sheet gets a Worktrees button next to
Create New Case; no header button anywhere), opening one dialog per case:

- the repository's worktrees, with the managed ones (.claude/worktrees)
  offering Open (start another session in it) and Remove…, an inline
  confirmation with "Also delete its branch" (git branch -d) whose git
  refusal is shown verbatim through textContent;
- a New worktree form (name, optional branch and start point) and a
  "Start sessions with" picker over every enabled, installed run mode,
  built from the registry catalogue as data, never an id branch.

Launching goes through run() with options.worktree and the same temporary
_runMode swap the custom-model entries use, so every mode keeps its own
path: claude and shell create through POST /api/sessions with the
worktree as workingDir, every other CLI through quick-start's worktree
field. Tabs are named w<n>-<worktree> and the Run button's mode is left
as it was.

Close Session offers "Kill & remove worktree" for a session that runs in
a Codeman-managed worktree; the removal waits for the server to confirm
the session is gone (closeSession now resolves true/false), and a dirty
tree keeps the worktree with git's reason in a toast that opens the
dialog. zh-CN strings and toast patterns for all of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-14 05:16:06 +02:00
parent 517e12fe1d
commit 8cffcf4be5
8 changed files with 1201 additions and 60 deletions
+30 -4
View File
@@ -1447,6 +1447,7 @@ class CodemanApp {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeWorktreeModal?.();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
@@ -10336,9 +10337,10 @@ class CodemanApp {
delete this.respawnActionLogs[sessionId];
}
/** Resolves true once the session is gone from the server, false when it is still there. */
async closeSession(sessionId, killMux = true) {
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
if (this._closingSessions.has(sessionId)) return false;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
@@ -10416,7 +10418,7 @@ class CodemanApp {
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
return;
return true;
}
// Still on the server (or the server is unreachable, in which case the resync
@@ -10431,6 +10433,7 @@ class CodemanApp {
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
return false;
}
// Request confirmation before closing a session
@@ -10445,6 +10448,20 @@ class CodemanApp {
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// A session in a Codeman-managed git worktree also offers to remove the worktree
// (git worktree remove, never forced) once the session is gone. Read now, before the
// session leaves this.sessions.
const worktree = this._managedWorktreeOfSession?.(session) || null;
this.pendingCloseWorktree = worktree;
const wtOption = document.getElementById('closeConfirmWorktree');
if (wtOption) {
wtOption.hidden = !worktree;
const wtName = document.getElementById('closeConfirmWorktreeName');
if (wtName) wtName.textContent = worktree ? worktree.name : '';
const wtBranch = document.getElementById('closeConfirmWorktreeBranch');
if (wtBranch) wtBranch.checked = false;
}
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
@@ -10472,15 +10489,24 @@ class CodemanApp {
cancelCloseSession() {
this.pendingCloseSessionId = null;
this.pendingCloseWorktree = null;
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killMux = true) {
/**
* `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only
* after the server confirms the session is gone (a live session inside is refused anyway),
* and only ever through `git worktree remove` without --force.
*/
async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) {
const sessionId = this.pendingCloseSessionId;
const worktree = removeWorktree ? this.pendingCloseWorktree : null;
const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
const gone = await this.closeSession(sessionId, killMux);
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
}
}
+52
View File
@@ -265,6 +265,48 @@
'New or link a case…': '新建或关联案例…',
'Case settings…': '案例设置…',
'No cases match': '没有匹配的案例',
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
// phone case sheet and Close Session's worktree option). Worktree and branch names,
// paths and git's own messages are user/git text and pass through untranslated.
'Git worktrees…': 'Git 工作树…',
'Git worktrees': 'Git 工作树',
'Close git worktrees': '关闭 Git 工作树',
Worktrees: '工作树',
'Worktrees…': '工作树…',
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
'Start sessions with': '启动会话所用',
'New worktree': '新建工作树',
Branch: '分支',
'Start from': '起点',
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
'Branch and start point': '分支和起点',
"A branch, tag or commit. Default: the case's current commit (HEAD).":
'分支、标签或提交。默认:案例当前提交(HEAD)。',
'Create & start session': '创建并启动会话',
'Loading worktrees…': '正在加载工作树…',
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
'detached HEAD': '游离 HEAD',
'main checkout': '主检出',
'not created by Codeman': '非 {name} 创建',
locked: '已锁定',
missing: '已丢失',
Open: '打开',
'Start a session in this worktree': '在此工作树中启动会话',
'Remove…': '移除…',
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
'Remove worktree': '移除工作树',
'Kill & remove worktree': '终止并移除工作树',
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
'Also delete its branch (git branch -d keeps a branch that is not merged)':
'同时删除其分支(git branch -d 会保留未合并的分支)',
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
'A launch is already in progress': '已有启动正在进行',
'Codeman did not answer.': '{name} 没有响应。',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
@@ -1252,6 +1294,16 @@
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
// words pass through untranslated.
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
[
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
],
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
+63
View File
@@ -1693,6 +1693,18 @@
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
app.js): kill it, then `git worktree remove` without --force. -->
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
<span class="close-option-title">Kill &amp; remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
</button>
<label class="close-option-worktree-branch">
<input type="checkbox" id="closeConfirmWorktreeBranch">
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
</label>
</div>
</div>
<div class="modal-footer-cancel">
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
@@ -1700,6 +1712,52 @@
</div>
</div>
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
<div class="modal-content worktree-modal">
<div class="modal-header">
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
<div class="worktree-list" id="worktreeList"></div>
<div class="form-row worktree-mode-row">
<label for="worktreeMode">Start sessions with</label>
<select id="worktreeMode" class="form-select"></select>
</div>
<fieldset class="worktree-new">
<legend>New worktree</legend>
<div class="form-row">
<label for="worktreeName">Name</label>
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
<span class="form-hint">New branch worktree-&lt;name&gt; from the case's current commit, unless you pick another below.</span>
</div>
<details class="worktree-advanced">
<summary>Branch and start point</summary>
<div class="form-row">
<label for="worktreeBranch">Branch</label>
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label for="worktreeBase">Start from</label>
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
</div>
</details>
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
</fieldset>
</div>
<div class="form-actions">
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create &amp; start session</button>
</div>
</div>
</div>
<!-- App Settings Modal -->
<div class="modal" id="appSettingsModal">
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
@@ -3663,6 +3721,11 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create New Case
</button>
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
<span aria-hidden="true">&#x2442;</span>
Worktrees
</button>
</div>
</div>
</div>
+493 -53
View File
@@ -128,14 +128,37 @@ const CASE_PICKER_REFRESH_MS = 5000;
/**
* Action rows at the bottom of the toolbar case picker. They replaced the "+"
* and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two
* actions, one click away, without two extra controls in the toolbar. The arrow
* keys reach them after the last case; Enter or a click runs `run`.
* actions, one click away, without two extra controls in the toolbar, and the git
* worktrees of the selected case joined them. The arrow keys reach them after the
* last case; Enter or a click runs `run`.
*/
const CASE_PICKER_ACTIONS = [
{ id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() },
{ id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() },
// The selected case's git worktrees (openWorktreeModal below): a row here rather than a
// toolbar button, for the same reason the two above replaced theirs.
{ id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() },
];
/** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/**
* The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is
* exactly `<case path>/.claude/worktrees/<name>` of a local case in `cases`, else null. Lexical
* on purpose (the browser cannot resolve links): the server re-checks every removal against the
* case's own repository, so a stale or wrong match can only be refused, never act elsewhere.
*/
function managedWorktreeOfDir(workingDir, cases) {
if (typeof workingDir !== 'string') return null;
const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir);
if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null;
const owner = (cases || []).find(
(c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker'
);
return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null;
}
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
@@ -1919,12 +1942,16 @@ Object.assign(CodemanApp.prototype, {
},
async runClaude(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
// that git worktree of the case and its tab is named after the worktree.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
`Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus terminal NOW, in the synchronous user-gesture context (button click).
// iOS Safari ignores programmatic focus() after any await, so this must happen
@@ -1933,25 +1960,32 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// A worktree is local by construction (the server refuses one for a remote or Docker
// case) and its folder came from the worktree list: no case lookup, never a scaffold.
caseData = { path: wt.path, location: 'local' };
} else {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const workingDir = caseData.path;
@@ -2024,8 +2058,8 @@ Object.assign(CodemanApp.prototype, {
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Find the highest existing w-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase);
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
@@ -2033,7 +2067,7 @@ Object.assign(CodemanApp.prototype, {
// Create all sessions in parallel for speed
const sessionNames = [];
for (let i = 0; i < tabCount; i++) {
sessionNames.push(`w${startNumber + i}-${caseName}`);
sessionNames.push(`w${startNumber + i}-${nameBase}`);
}
// Build env overrides from global + case settings (case overrides global)
@@ -2140,40 +2174,49 @@ Object.assign(CodemanApp.prototype, {
},
async runShell(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
const shellCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
`Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`,
'1;33'
);
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// Local by construction, folder from the worktree list (see runClaude).
caseData = { path: wt.path, location: 'local' };
} else {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const selectedCase = wt ? null : (this.cases || []).find(c => c.name === caseName);
const isRemoteCase =
caseData.location === 'remote' ||
caseData.location === 'docker' ||
@@ -2206,13 +2249,13 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Find the highest existing s-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
// Find the highest existing s-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase, 's');
// Create all shell sessions in parallel
const sessionNames = [];
for (let i = 0; i < shellCount; i++) {
sessionNames.push(`s${startNumber + i}-${caseName}`);
sessionNames.push(`s${startNumber + i}-${nameBase}`);
}
// Step 1: Create all sessions in parallel
@@ -2331,16 +2374,19 @@ Object.assign(CodemanApp.prototype, {
buildConfig: () => null,
});
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
// session starts through the same path as any other, only in the worktree's folder.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount(options.count);
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
`Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
@@ -2369,13 +2415,15 @@ Object.assign(CodemanApp.prototype, {
// into `claude --settings`); none of these CLIs has an /effort. Each of
// the eight bodies this launcher replaced carried that rule as a comment.
const firstSessionId = await this._launchQuickStartInstances(
caseName,
// Only names the tabs (`w<n>-<name>`): a worktree session is named after the worktree.
wt ? wt.name : caseName,
tabCount,
entry.label,
(sessionName) => ({
caseName,
mode,
sessionName,
...(wt ? { worktree: wt.name } : {}),
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
...(isRemote ? {} : {
...(entry.buildConfig(globalSettings) || {}),
@@ -5007,6 +5055,398 @@ Object.assign(CodemanApp.prototype, {
// Remove animation class after it plays
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
// ═══════════════════════════════════════════════════════════════
// Git worktrees of a case (src/git-worktrees.ts, routes/worktree-routes.ts)
// ═══════════════════════════════════════════════════════════════
//
// One dialog per case: its worktrees (Open, Remove), and a form for a new one that starts a
// session in it at once. Sessions start through run() with `options.worktree`, so every run
// mode keeps its own launch path (POST /api/sessions for claude and shell, quick-start with
// `worktree` for the rest). Everything git or the user named (worktree and branch names, paths,
// git's own refusals) renders through textContent and carries data-i18n-skip.
/** Open the worktree dialog for `caseName` (default: the case selected in the toolbar). */
openWorktreeModal(caseName) {
const modal = document.getElementById('worktreeModal');
const name = caseName || document.getElementById('quickStartCase')?.value || '';
if (!modal || !name) return;
this._worktreeCase = name;
this._worktreeData = null;
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
const caseLabel = document.getElementById('worktreeModalCase');
if (caseLabel) caseLabel.textContent = name;
for (const id of ['worktreeName', 'worktreeBranch', 'worktreeBase']) {
const input = document.getElementById(id);
if (input) input.value = '';
}
this._setWorktreeFormError('');
this._fillWorktreeModeSelect();
this._renderWorktreeList();
modal.classList.add('active');
void this._loadWorktrees();
// Not on a touch device: focusing the field raises the on-screen keyboard over the list.
const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.();
if (!touch) setTimeout(() => document.getElementById('worktreeName')?.focus?.(), 50);
},
/** The phone case sheet's Worktrees button: the selected case's worktrees. */
showWorktreesFromMobile() {
this.closeMobileCasePicker();
this.openWorktreeModal();
},
closeWorktreeModal() {
const modal = document.getElementById('worktreeModal');
// Called on every Escape (app.js): a no-op unless the dialog is open.
if (!modal || !modal.classList.contains('active')) return;
modal.classList.remove('active');
this._worktreeCase = null;
this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1;
},
_worktreesUrl(caseName, suffix = '') {
return `/api/cases/${encodeURIComponent(caseName)}/worktrees${suffix}`;
},
/** A JSON call that keeps the server's error text, which `_apiJson()` drops. */
async _worktreeRequest(path, opts = {}) {
const res = await this._api(path, opts);
if (!res) return { ok: false, error: 'Codeman did not answer.' };
let body = null;
try {
body = await res.json();
} catch {
/* an empty or non-JSON body: the status says enough */
}
if (res.ok && body?.success !== false) return { ok: true, data: body?.data ?? body };
return { ok: false, status: res.status, error: body?.error || `Request failed (${res.status})` };
},
async _loadWorktrees() {
const caseName = this._worktreeCase;
if (!caseName) return;
const seq = (this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1);
this._setWorktreeStatus('Loading worktrees…');
const r = await this._worktreeRequest(this._worktreesUrl(caseName));
if (seq !== this._worktreeLoadSeq || caseName !== this._worktreeCase) return;
this._worktreeData = r.ok ? r.data : null;
this._setWorktreeStatus(r.ok ? '' : r.error, !r.ok);
// A case that cannot list worktrees (not a repository, a Docker or remote case) cannot get one.
const createBtn = document.getElementById('worktreeCreateBtn');
if (createBtn) createBtn.disabled = !r.ok;
this._renderWorktreeList();
},
_setWorktreeStatus(text, isError = false) {
const el = document.getElementById('worktreeStatus');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
el.classList.toggle('worktree-status-error', !!isError);
},
_setWorktreeFormError(text) {
const el = document.getElementById('worktreeFormError');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
},
/** Every enabled, installed run mode, the shell last; the Run button's mode preselected. */
_fillWorktreeModeSelect() {
const select = document.getElementById('worktreeMode');
if (!select) return;
// Every launchable registry entry (agents and the shell), as data: no id is branched on here.
const modes = [];
for (const cli of registryCliCatalog()) {
if (cli.enabled && cli.available !== false) modes.push({ id: cli.id, label: cli.label });
}
if (modes.length === 0) {
// A cached page from before the registry catalogue: the built-in modes.
modes.push({ id: 'claude', label: 'Claude Code' });
for (const [id, entry] of Object.entries(RUN_MODE_LAUNCH)) modes.push({ id, label: entry.label });
modes.push({ id: 'shell', label: 'Terminal / Shell' });
}
select.replaceChildren(
...modes.map((m) => {
const option = document.createElement('option');
option.value = m.id;
option.textContent = m.label;
return option;
})
);
const current = this._runMode || 'claude';
select.value = modes.some((m) => m.id === current) ? current : modes[0].id;
},
_selectedWorktreeMode() {
return document.getElementById('worktreeMode')?.value || this._runMode || 'claude';
},
_renderWorktreeList() {
const list = document.getElementById('worktreeList');
if (!list) return;
list.replaceChildren();
const data = this._worktreeData;
if (!data) return;
for (const wt of data.worktrees) list.appendChild(this._buildWorktreeRow(wt));
if (!data.worktrees.some((wt) => wt.managed)) {
const empty = document.createElement('div');
empty.className = 'worktree-empty';
empty.textContent = 'No worktrees yet. Create one below.';
list.appendChild(empty);
}
},
_buildWorktreeRow(wt) {
const row = document.createElement('div');
row.className = 'worktree-row' + (wt.managed ? ' worktree-row--managed' : '');
const head = document.createElement('div');
head.className = 'worktree-row-head';
const label = document.createElement('span');
label.className = 'worktree-row-name';
label.setAttribute('data-i18n-skip', '');
label.textContent = wt.managed ? `⑂ ${wt.name}` : wt.path.split('/').pop() || wt.path;
head.appendChild(label);
const badge = (text, extra = '', skip = false) => {
const b = document.createElement('span');
b.className = `worktree-badge${extra ? ` ${extra}` : ''}`;
if (skip) b.setAttribute('data-i18n-skip', '');
b.textContent = text;
head.appendChild(b);
};
if (wt.branch) badge(wt.branch, 'worktree-badge-branch', true);
else if (wt.detached) badge('detached HEAD', 'worktree-badge-branch');
if (wt.main) badge('main checkout');
else if (!wt.managed) badge('not created by Codeman', 'worktree-badge-dim');
if (wt.locked) badge('locked', 'worktree-badge-warn');
if (wt.prunable) badge('missing', 'worktree-badge-warn');
if (wt.sessionCount > 0) {
badge(`${wt.sessionCount} session${wt.sessionCount === 1 ? '' : 's'}`, 'worktree-badge-live');
}
if (wt.managed && !wt.prunable) {
const actions = document.createElement('span');
actions.className = 'worktree-row-actions';
const open = document.createElement('button');
open.type = 'button';
open.className = 'wt-btn wt-btn-sm';
open.textContent = 'Open';
open.title = 'Start a session in this worktree';
open.addEventListener('click', () => void this.openWorktreeSession(wt.name));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'wt-btn wt-btn-sm wt-btn-danger';
remove.textContent = 'Remove…';
remove.addEventListener('click', () => this.confirmWorktreeRemoval(wt.name));
actions.append(open, remove);
head.appendChild(actions);
}
row.appendChild(head);
// A managed worktree's folder is always `.claude/worktrees/<name>` of the case: say just that,
// so the long repository prefix (already on the main row) is not repeated on every row.
const path = document.createElement('div');
path.className = 'worktree-row-path';
path.setAttribute('data-i18n-skip', '');
path.textContent = wt.managed ? `.claude/worktrees/${wt.name}` : this._shortenHomePath(wt.path);
path.title = wt.path;
row.appendChild(path);
if (wt.managed && this._worktreeRemoveConfirm === wt.name) row.appendChild(this._buildWorktreeRemoveConfirm(wt));
return row;
},
_buildWorktreeRemoveConfirm(wt) {
const box = document.createElement('div');
box.className = 'worktree-remove-confirm';
const note = document.createElement('p');
note.className = 'worktree-remove-note';
note.textContent =
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.';
box.appendChild(note);
let checkbox = null;
if (wt.branch) {
const lbl = document.createElement('label');
lbl.className = 'worktree-remove-branch';
checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const text = document.createElement('span');
text.textContent = 'Also delete its branch (git branch -d keeps a branch that is not merged)';
lbl.append(checkbox, text);
box.appendChild(lbl);
}
if (this._worktreeRemoveError?.name === wt.name) {
const err = document.createElement('pre');
err.className = 'worktree-remove-error';
err.setAttribute('data-i18n-skip', '');
err.textContent = this._worktreeRemoveError.text;
box.appendChild(err);
}
const buttons = document.createElement('div');
buttons.className = 'worktree-remove-buttons';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'wt-btn wt-btn-sm';
cancel.textContent = 'Cancel';
cancel.addEventListener('click', () => this.cancelWorktreeRemoval());
const confirm = document.createElement('button');
confirm.type = 'button';
confirm.className = 'wt-btn wt-btn-sm wt-btn-danger';
confirm.textContent = 'Remove worktree';
confirm.addEventListener('click', async () => {
confirm.disabled = true;
try {
await this.removeWorktreeFromModal(wt.name, !!checkbox?.checked);
} finally {
confirm.disabled = false;
}
});
buttons.append(cancel, confirm);
box.appendChild(buttons);
return box;
},
confirmWorktreeRemoval(name) {
this._worktreeRemoveConfirm = name;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
cancelWorktreeRemoval() {
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
async removeWorktreeFromModal(name, deleteBranch) {
const caseName = this._worktreeCase;
if (!caseName) return;
const r = await this.removeCaseWorktree(caseName, name, deleteBranch);
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._worktreeRemoveError = { name, text: r.error };
this._renderWorktreeList();
return;
}
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
await this._loadWorktrees();
},
/** DELETE one worktree (never forced); shared by the dialog and Close Session. Toasts the outcome. */
async removeCaseWorktree(caseName, name, deleteBranch) {
const query = deleteBranch ? '?deleteBranch=true' : '';
const r = await this._worktreeRequest(this._worktreesUrl(caseName, `/${encodeURIComponent(name)}${query}`), {
method: 'DELETE',
});
if (r.ok) {
const removed = r.data?.removed || { name };
if (removed.branchError) {
this.showToast?.(
`Worktree ${removed.name} removed. Branch ${removed.branch} kept: ${removed.branchError}`,
'warning'
);
} else if (removed.branchDeleted) {
this.showToast?.(`Worktree ${removed.name} and branch ${removed.branch} removed`, 'success');
} else {
this.showToast?.(`Worktree ${removed.name} removed`, 'success');
}
}
return r;
},
async createWorktreeFromModal() {
const caseName = this._worktreeCase;
if (!caseName) return;
const name = (document.getElementById('worktreeName')?.value || '').trim();
const branch = (document.getElementById('worktreeBranch')?.value || '').trim();
const base = (document.getElementById('worktreeBase')?.value || '').trim();
if (!WORKTREE_NAME_RE.test(name) || name.includes('..') || /\.(lock)?$/i.test(name)) {
this._setWorktreeFormError(
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.'
);
return;
}
this._setWorktreeFormError('');
const mode = this._selectedWorktreeMode();
const btn = document.getElementById('worktreeCreateBtn');
if (btn) btn.disabled = true;
try {
const r = await this._worktreeRequest(this._worktreesUrl(caseName), {
method: 'POST',
body: { name, ...(branch ? { branch } : {}), ...(base ? { base } : {}) },
});
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._setWorktreeFormError(r.error);
return;
}
const created = r.data.worktree;
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
} finally {
if (btn) btn.disabled = false;
}
},
async openWorktreeSession(name) {
const caseName = this._worktreeCase;
const wt = this._worktreeData?.worktrees?.find((w) => w.managed && w.name === name);
if (!caseName || !wt) return;
const mode = this._selectedWorktreeMode();
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
},
/**
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
* entries use, so the Run button's chosen mode is untouched afterwards.
*/
async _launchInWorktree(worktree, mode) {
if (this._runInFlight) {
this.showToast?.('A launch is already in progress', 'info');
return;
}
const previous = this._runMode;
this._runMode = mode || previous;
try {
await this.run({ count: 1, worktree });
} finally {
this._runMode = previous;
}
},
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
_managedWorktreeOfSession(session) {
if (!session || session.remote || session.docker) return null;
return managedWorktreeOfDir(session.workingDir, this.cases);
},
/**
* Close Session's "Kill & remove worktree", once the session is gone. git's refusal (a dirty
* tree) is the answer: the worktree stays, and the toast says why and opens the dialog.
*/
async _removeWorktreeAfterClose(wt, deleteBranch) {
const r = await this.removeCaseWorktree(wt.caseName, wt.name, deleteBranch);
if (r.ok) {
if (this._worktreeCase === wt.caseName) void this._loadWorktrees();
return;
}
this.showToast?.(`Worktree ${wt.name} was kept: ${r.error}`, 'error', {
duration: 15000,
action: { label: 'Worktrees…', onClick: () => this.openWorktreeModal(wt.caseName) },
});
},
});
Object.defineProperty(CodemanApp.prototype, 'runMode', {
+246
View File
@@ -7793,6 +7793,9 @@ html[data-skin="og"] .welcome-primary:hover {
.mobile-case-picker-footer {
padding: 12px 20px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
/* Create New Case and Worktrees share the row, half each. */
display: flex;
gap: 8px;
}
.btn-case-create-mobile {
@@ -8148,6 +8151,249 @@ html[data-skin="og"] .welcome-primary:hover {
border-top: 1px solid var(--border);
}
/* Close Session: "Kill & remove worktree" (a session in a Codeman-managed git
worktree only). The wrapper re-asserts [hidden]: it lays out as a column. */
.close-option-worktree {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.close-option-worktree[hidden] {
display: none;
}
.close-option-worktree-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-dim);
padding: 0 0.25rem;
cursor: pointer;
}
.close-option-worktree-branch input {
margin-top: 0.1rem;
}
/* Git worktrees dialog (session-ui.js openWorktreeModal). Its own small button
set: the skin block greys `.btn-toolbar.btn-primary` on non-OG skins, and this
dialog needs a primary action that reads as one on every skin. */
.modal-content.worktree-modal {
max-width: 640px;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.worktree-modal > .modal-body {
overflow-y: auto;
min-height: 0;
}
.worktree-modal-case {
font-family: 'SF Mono', Monaco, monospace;
font-weight: 500;
color: var(--text-dim);
}
.worktree-intro {
margin: 0 0 0.6rem;
font-size: 0.72rem;
line-height: 1.45;
}
.worktree-status {
font-size: 0.78rem;
color: var(--text-dim);
margin-bottom: 0.6rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.worktree-status[hidden],
.worktree-form-error[hidden] {
display: none;
}
.worktree-status-error,
.worktree-form-error {
color: var(--red);
}
.worktree-form-error {
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin-top: 0.25rem;
}
.worktree-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.9rem;
}
.worktree-row {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.65rem;
background: var(--control-bg);
}
.worktree-row--managed {
border-color: var(--border-light);
}
.worktree-row-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.45rem;
}
.worktree-row-name {
font-weight: 600;
font-size: 0.85rem;
color: var(--text);
overflow-wrap: anywhere;
}
.worktree-badge {
font-size: 0.66rem;
line-height: 1.5;
padding: 0 0.4rem;
border-radius: 999px;
border: 1px solid var(--border-light);
color: var(--text-dim);
white-space: nowrap;
}
.worktree-badge-branch {
font-family: 'SF Mono', Monaco, monospace;
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.35);
white-space: normal;
overflow-wrap: anywhere;
}
.worktree-badge-dim {
color: var(--text-muted);
}
.worktree-badge-warn {
color: var(--yellow);
border-color: var(--yellow);
}
.worktree-badge-live {
color: var(--green);
border-color: var(--green);
}
.worktree-row-actions {
display: inline-flex;
gap: 0.35rem;
margin-left: auto;
}
.worktree-row-path {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
color: var(--text-muted);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.worktree-empty {
font-size: 0.75rem;
color: var(--text-muted);
padding: 0.25rem 0.1rem;
}
.worktree-remove-confirm {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--border-light);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.worktree-remove-note {
margin: 0;
font-size: 0.72rem;
color: var(--text-dim);
}
.worktree-remove-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--text-dim);
cursor: pointer;
}
.worktree-remove-error {
margin: 0;
font-size: 0.72rem;
color: var(--red);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: rgba(224, 99, 99, 0.08);
border-radius: 6px;
padding: 0.4rem 0.5rem;
}
.worktree-remove-buttons {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.worktree-mode-row {
max-width: 320px;
}
.worktree-new {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem 0.4rem;
margin: 0;
min-width: 0;
}
.worktree-new > legend {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
padding: 0 0.3rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.worktree-advanced {
margin-bottom: 0.5rem;
}
.worktree-advanced > summary {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.wt-btn {
font: inherit;
font-size: 0.8rem;
padding: 0.4rem 0.85rem;
border-radius: var(--btn-radius);
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--text);
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth);
}
.wt-btn:hover:not(:disabled) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
}
.wt-btn:disabled {
opacity: 0.5;
cursor: default;
}
.wt-btn-sm {
font-size: 0.72rem;
padding: 0.2rem 0.6rem;
}
.wt-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
font-weight: 600;
}
.wt-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.wt-btn-danger {
color: var(--red);
border-color: rgba(224, 99, 99, 0.45);
}
.wt-btn-danger:hover:not(:disabled) {
background: rgba(224, 99, 99, 0.12);
border-color: var(--red);
}
.modal-session-name {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.8rem;
+6 -3
View File
@@ -42,11 +42,14 @@ afterAll(() => {
en.dom.window.close();
});
/** Latin words left after removing what may stay: key names, the AI acronym, the N placeholder. */
/**
* Latin words left after removing what may stay: key names, the AI acronym, the N placeholder,
* and Git, a product name every translation that names it keeps (the Git status rows too).
*/
const leftover = (text: string) =>
text
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option)\+\w+/g, '')
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N)\b/g, '')
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N|Git)\b/g, '')
.match(/[A-Za-z]+/g) ?? [];
const helpers = new Function(
@@ -204,7 +207,7 @@ describe('the case picker rows and the host-window and dictation toasts in zh-CN
);
it('finds the strings it checks (the check is not vacuous)', () => {
expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026']);
expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026', 'Git worktrees\u2026']);
expect(SESSION_UI).toContain('<div class="case-combobox-empty">No cases match</div>');
expect(toasts.sort()).toEqual([
'Could not open a new window for this dashboard',
+153
View File
@@ -0,0 +1,153 @@
/**
* @fileoverview The git worktree UI in Chinese (zh-CN), run through the real translator over the
* real index.html in JSDOM:
*
* - every application string of the worktree dialog, Close Session's worktree option and the
* phone case sheet's Worktrees button reads in Chinese, with no English left beyond Git (a
* product name), git's own commands and the `.claude/worktrees/` and `worktree-<name>` paths;
* - the strings session-ui.js inserts at runtime (row badges, buttons, the removal note, the
* validation message) and its toasts translate too, the toasts keeping the user's worktree and
* branch names verbatim;
* - the slots holding user or git text (case name, repository path, worktree name) are skipped.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const SESSION_UI = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
type Api = { t(s: string): string; configure(o: Record<string, string>): void; start(): void };
function translator(language: string) {
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language });
return { dom, api };
}
const zh = translator('zh-CN');
const en = translator('en');
afterAll(() => {
zh.dom.window.close();
en.dom.window.close();
});
/**
* Latin left once the names that stay are removed: Git (product), git and its commands, the
* `.claude/worktrees/` path, the branch template, HEAD, and the display name (Codeman by default).
*/
const leftover = (text: string) =>
text
.replace(/git (worktree remove|branch -d)/g, '')
.replace(/\.claude\/worktrees\/|worktree-<name>|\bHEAD\b|\bGit\b|\bgit\b|\bCodeman\b/g, '')
.match(/[A-Za-z]+/g) ?? [];
/** Visible text of the static surfaces, one entry per text-bearing element (skips excluded). */
function staticStrings(doc: Document): string[] {
const roots = [
doc.getElementById('worktreeModal')!,
doc.getElementById('closeConfirmWorktree')!,
doc.querySelector('.btn-case-worktrees-mobile')!,
];
const out: string[] = [];
for (const root of roots) {
const walker = doc.createTreeWalker(root, 4 /* SHOW_TEXT */);
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const text = (n.nodeValue || '').trim();
if (!text || (n.parentElement && n.parentElement.closest('[data-i18n-skip]'))) continue;
out.push(text);
}
for (const el of root.querySelectorAll('[aria-label], [title]')) {
for (const attr of ['aria-label', 'title']) {
const v = el.getAttribute(attr);
if (v) out.push(v);
}
}
}
return out;
}
describe('the git worktree UI in zh-CN', () => {
const english = staticStrings(en.dom.window.document);
const chinese = staticStrings(zh.dom.window.document);
it('finds the strings it checks (not vacuous)', () => {
expect(english).toContain('Create & start session');
expect(english).toContain('Kill & remove worktree');
expect(english).toContain('Worktrees');
expect(english).toContain('Close git worktrees');
expect(english.length).toBe(chinese.length);
expect(english.length).toBeGreaterThan(15);
});
it('translates every static string, leaving only Git, git commands and paths in Latin', () => {
// Symbols with no words (the close cross, the worktree glyph) have nothing to translate.
const bad = chinese.filter(
(text, i) => /[A-Za-z]/.test(english[i]) && (text === english[i] || leftover(text).length > 0)
);
expect(bad).toEqual([]);
});
it('keeps the user/git slots out of the translator', () => {
const doc = zh.dom.window.document;
for (const id of ['worktreeModalCase', 'closeConfirmWorktreeName']) {
expect(doc.getElementById(id)!.hasAttribute('data-i18n-skip'), id).toBe(true);
}
});
it('translates the strings session-ui.js inserts at runtime', () => {
const runtime = [
'Git worktrees…',
'Loading worktrees…',
'No worktrees yet. Create one below.',
'detached HEAD',
'main checkout',
'not created by Codeman',
'locked',
'missing',
'Open',
'Start a session in this worktree',
'Remove…',
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.',
'Also delete its branch (git branch -d keeps a branch that is not merged)',
'Remove worktree',
'Cancel',
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.',
'A launch is already in progress',
'Codeman did not answer.',
'Worktrees…',
'2 sessions',
];
// Each literal is really in session-ui.js (the count badge is built from `${n} session`).
for (const s of runtime.filter((r) => r !== '2 sessions')) expect(SESSION_UI.includes(`'${s}'`), s).toBe(true);
expect(SESSION_UI).toContain('session${wt.sessionCount === 1 ?');
const bad = runtime.filter((s) => {
const text = zh.api.t(s);
return text === s || leftover(text).length > 0 || en.api.t(s) !== s;
});
expect(bad).toEqual([]);
});
it('translates the toasts and keeps the names verbatim', () => {
expect(zh.api.t('Worktree fix-login created on branch feat/login')).toBe('已创建工作树 fix-login,分支 feat/login');
expect(zh.api.t('Worktree fix-login and branch worktree-fix-login removed')).toBe(
'已移除工作树 fix-login 和分支 worktree-fix-login'
);
expect(zh.api.t('Worktree fix-login removed')).toBe('已移除工作树 fix-login');
expect(
zh.api.t("Worktree w2 removed. Branch worktree-w2 kept: error: the branch 'worktree-w2' is not fully merged")
).toBe("已移除工作树 w2。保留了分支 worktree-w2:error: the branch 'worktree-w2' is not fully merged");
expect(zh.api.t('Worktree w1 was kept: git refused to remove the worktree: fatal: x')).toBe(
'工作树 w1 已保留:git refused to remove the worktree: fatal: x'
);
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* @fileoverview The git worktree launch wiring in session-ui.js, in a vm sandbox (no real DOM),
* the way test/run-mode-ui.test.ts drives the run*() methods:
*
* - `managedWorktreeOfDir()` names only `<local case path>/.claude/worktrees/<valid-name>`;
* - a worktree launch goes through the EXISTING create paths with the worktree's folder:
* POST /api/sessions with `workingDir` for claude and shell, quick-start with `worktree` for
* every other CLI; never a case lookup or scaffold, and tabs named after the worktree;
* - `_launchInWorktree()` swaps the run mode for the one launch and puts it back.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const SESSION_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
const WT = { caseName: 'myrepo', name: 'fix-login', path: '/cases/myrepo/.claude/worktrees/fix-login' };
function load(fetchImpl: (url: string, init?: { method?: string; body?: string }) => Promise<unknown>) {
const elements: Record<string, any> = {
// The toolbar still points at ANOTHER case: a worktree launch must not read it.
quickStartCase: { value: 'other-case' },
tabCount: { value: '3' },
};
const requests: Array<{ url: string; method?: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string, init?: { method?: string; body?: string }) => {
requests.push({ url, method: init?.method, body: init?.body ? JSON.parse(init.body) : undefined });
return fetchImpl(url, init);
},
console,
setTimeout,
});
vm.runInContext(SESSION_UI, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.sessions = new Map();
app.cases = [
{ name: 'myrepo', path: '/cases/myrepo', location: 'local' },
{ name: 'dock', path: '/cases/dock', location: 'docker' },
];
app.getCaseSettings = () => ({});
app.loadAppSettingsFromStorage = () => ({});
app.buildEnvOverrides = () => ({});
app.getEffortSetting = () => undefined;
app.getAdvisorSetting = () => undefined;
app.isRalphTrackerEnabledByDefault = () => false;
app.getTerminalDimensions = () => null;
app.loadQuickStartCases = () => {};
app.isCliAvailable = () => true;
app._ensureCreatedSessionVisible = async () => {};
app.selectSession = vi.fn(async () => {});
return { app, context, requests };
}
const json = (data: unknown) => ({ ok: true, status: 200, json: async () => data });
describe('managedWorktreeOfDir', () => {
const { context } = load(async () => json({}));
const fn = (context as unknown as { managedWorktreeOfDir: (d: unknown, c: unknown) => unknown }).managedWorktreeOfDir;
const cases = [
{ name: 'myrepo', path: '/cases/myrepo', location: 'local' },
{ name: 'linked', path: '/srv/linked', location: 'linked-local' },
{ name: 'dock', path: '/cases/dock', location: 'docker' },
];
it('names a worktree folder of a local or linked case', () => {
expect(fn('/cases/myrepo/.claude/worktrees/fix-login', cases)).toEqual(WT);
expect(fn('/srv/linked/.claude/worktrees/a.b_c/', cases)).toEqual({
caseName: 'linked',
name: 'a.b_c',
path: '/srv/linked/.claude/worktrees/a.b_c',
});
});
it('ignores the case folder, a subfolder, unknown or Docker cases and invalid names', () => {
expect(fn('/cases/myrepo', cases)).toBeNull();
expect(fn('/cases/myrepo/.claude/worktrees/fix-login/src', cases)).toBeNull();
expect(fn('/elsewhere/.claude/worktrees/x', cases)).toBeNull();
expect(fn('/cases/dock/.claude/worktrees/x', cases)).toBeNull();
expect(fn('/cases/myrepo/.claude/worktrees/-x', cases)).toBeNull();
expect(fn('/cases/myrepo/.claude/worktrees/a..b', cases)).toBeNull();
expect(fn(undefined, cases)).toBeNull();
});
});
describe('launching in a worktree', () => {
it('claude: POST /api/sessions in the worktree folder, one tab named after the worktree', async () => {
const { app, requests } = load(async (url) => {
if (url === '/api/sessions') return json({ success: true, data: { session: { id: 's-1' } } });
return json({ success: true });
});
await app.runClaude({ count: 1, worktree: WT });
const create = requests.filter((r) => r.url === '/api/sessions');
expect(create).toHaveLength(1);
expect(create[0].body).toMatchObject({ workingDir: WT.path, name: 'w1-fix-login' });
expect(requests.some((r) => r.url.startsWith('/api/cases'))).toBe(false);
expect(requests.some((r) => r.url === '/api/sessions/s-1/interactive')).toBe(true);
expect(app.selectSession).toHaveBeenCalledWith('s-1');
});
it('shell: POST /api/sessions in the worktree folder, no case lookup', async () => {
const { app, requests } = load(async (url) => {
if (url === '/api/sessions') return json({ success: true, data: { session: { id: 's-2' } } });
return json({ success: true });
});
await app.runShell({ count: 1, worktree: WT });
expect(requests.find((r) => r.url === '/api/sessions')?.body).toMatchObject({
workingDir: WT.path,
mode: 'shell',
name: 's1-fix-login',
});
expect(requests.some((r) => r.url.startsWith('/api/cases'))).toBe(false);
});
it('other CLIs: quick-start with the case and the worktree name', async () => {
const { app, requests } = load(async (url) => {
if (url === '/api/codex/status') return json({ success: true, data: { available: true } });
if (url === '/api/quick-start') return json({ success: true, data: { sessionId: 's-3' } });
return json({ success: true });
});
await app._runCliMode('codex', { count: 1, worktree: WT });
const qs = requests.filter((r) => r.url === '/api/quick-start');
expect(qs).toHaveLength(1);
expect(qs[0].body).toMatchObject({
caseName: 'myrepo',
mode: 'codex',
worktree: 'fix-login',
sessionName: 'w1-fix-login',
});
});
it('_launchInWorktree runs ONE session of the chosen mode and restores the Run button mode', async () => {
const { app } = load(async () => json({}));
app._runMode = 'claude';
const seen: Array<{ mode: string; options: unknown }> = [];
app.run = vi.fn(async (options: unknown) => {
seen.push({ mode: app._runMode, options });
});
await app._launchInWorktree(WT, 'codex');
expect(seen).toEqual([{ mode: 'codex', options: { count: 1, worktree: WT } }]);
expect(app._runMode).toBe('claude');
});
it('closing offers worktree removal only for a session in a managed worktree', () => {
const { app } = load(async () => json({}));
expect(app._managedWorktreeOfSession({ workingDir: WT.path })).toEqual(WT);
expect(app._managedWorktreeOfSession({ workingDir: '/cases/myrepo' })).toBeNull();
expect(app._managedWorktreeOfSession({ workingDir: WT.path, remote: { hostId: 'x' } })).toBeNull();
});
});