feat(cases): rename a case and its folder, with a lock and live-session handling

This commit is contained in:
Devvyn committed 2026-10-23 04:12:37 +08:00
1 parent 002de32a08
commit ab0bc0aecd
14 files changed
+884

No files matched your search

+1
View File
@@ -1914,6 +1914,7 @@ const SSE_EVENTS = {
CASE_CREATED: 'case:created',
CASE_LINKED: 'case:linked',
CASE_DELETED: 'case:deleted',
CASE_RENAMED: 'case:renamed',
CASE_ORDER_CHANGED: 'case:order-changed',
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
+67
View File
@@ -4957,6 +4957,12 @@ Object.assign(CodemanApp.prototype, {
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
${
c.location === 'docker' || c.location === 'remote'
? ''
: `<button class="case-manage-btn" onclick="app.renameCase(${escapeHtml(JSON.stringify(c.name))})"
title="Rename case (and its folder)">&#x270E;</button>`
}
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button>
</div>
@@ -4989,6 +4995,67 @@ Object.assign(CodemanApp.prototype, {
await this.saveCaseOrder(reordered.map(c => c.name));
},
/**
* Rename a case and its folder (POST /api/cases/:name/rename). A linked case asks whether the real folder
* should be renamed too (Cancel renames only the alias). When live sessions have the folder open the server
* refuses and lists them; they are only stopped after a second, explicit confirmation.
*/
async renameCase(name) {
const info = (this.cases || []).find((c) => c.name === name);
const entered = prompt(`Rename case "${name}" to (letters, numbers, - and _):`, name);
const newName = (entered || '').trim();
if (!newName || newName === name) return;
if (!/^[a-zA-Z0-9_-]+$/.test(newName)) {
this.showToast('Use only letters, numbers, hyphens and underscores', 'error');
return;
}
const body = { newName };
if (info?.linked) {
body.renameFolder = confirm(
`Also rename the folder on disk?\n\n${info.path}\n\nOK renames the folder to "${newName}" next to it. ` +
`Cancel keeps the folder and only changes the case name.`
);
}
const send = async () => {
const res = await fetch(`/api/cases/${encodeURIComponent(name)}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json().catch(() => ({ success: false, error: 'Unexpected response' }));
};
try {
let data = await send();
if (data.requiresStop) {
const list = (data.sessions || []).map((s) => ` - ${s.name}`).join('\n');
if (!confirm(`These sessions have the folder open and will be stopped (nothing is restarted):\n\n${list}\n\nStop them and rename?`)) {
return;
}
body.stopSessions = true;
data = await send();
}
if (!data.success) {
this.showToast(data.error || 'Failed to rename case', 'error');
return;
}
this.showToast(`Case renamed to "${newName}"`, 'success');
// The per-case run options are stored under the case name in this browser.
try {
const stored = localStorage.getItem('caseSettings_' + name);
if (stored !== null) {
localStorage.setItem('caseSettings_' + newName, stored);
localStorage.removeItem('caseSettings_' + name);
}
} catch { /* storage blocked: the options fall back to their defaults */ }
const select = document.getElementById('quickStartCase');
const current = select?.value;
await this.loadQuickStartCases(current === name ? newName : current);
this.renderCaseManageList();
} catch (err) {
this.showToast('Failed to rename case: ' + err.message, 'error');
}
},
async deleteCase(name) {
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
return;
+140
View File
@@ -30,6 +30,7 @@ import {
LinkCaseSchema,
CaseOrderSchema,
CaseDefaultGroupSchema,
RenameCaseSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
@@ -52,6 +53,14 @@ import {
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import {
CaseRenameError,
moveCaseFolder,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../../case-rename.js';
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
@@ -1747,6 +1756,137 @@ export function registerCaseRoutes(
}
});
// ========== Rename Case ==========
/**
* Rename a case: its name and, with it, its folder.
*
* - A case under the cases directory IS its folder name, so it always moves.
* - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only
* with `renameFolder: true` (it can be shared with other tools).
* - Remote and Docker cases are not renamable here: their folder lives on another host or in a container
* whose name derives from the case name.
*
* ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live
* sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which
* case they are ended first. The folder is held by an in-process lock for the whole operation, so a
* session cannot start in it half-moved.
*/
app.post('/api/cases/:name/rename', async (req, reply) => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
const body = parseBody(RenameCaseSchema, req.body);
const newName = body.newName;
const fail = (status: number, code: ApiErrorCode, message: string, extra: Record<string, unknown> = {}) => {
reply.code(status);
return { ...createErrorResponse(code, message), ...extra };
};
if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one');
// Remote and Docker cases first, so a same-named local case cannot be confused for them.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) ||
dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner))
) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here');
}
const casesBase = resolveCasesDir(user);
const linkedCases = await readLinkedCases();
const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req));
if (isLinked && !isAdmin(req)) return fail(403, ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
const oldPath = isLinked ? linkedCases[name] : join(casesBase, name);
// The new name must be free in every namespace the list merges.
const localTaken = existsSync(join(casesBase, newName));
const taken =
localTaken ||
Boolean(linkedCases[newName]) ||
remoteCases.some((c) => c.name === newName) ||
dockerCases.some((c) => c.name === newName);
if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`);
const state = await probePath(oldPath);
if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
if (state === 'unknown') {
return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true }));
}
const moveFolder = !isLinked || body.renameFolder === true;
const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName);
if (moveFolder && newPath !== oldPath && (await probePath(newPath, { pastCap: true })) !== 'absent') {
return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`);
}
// Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running.
const open = moveFolder
? Array.from(ctx.sessions.values()).filter(
(s) => s.workingDir === oldPath || s.workingDir.startsWith(oldPath + '/')
)
: [];
if (open.some((s) => !canAccessOwned(user, s.owner))) {
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
}
if (open.length > 0 && !body.stopSessions) {
return fail(
409,
ApiErrorCode.CONFLICT,
`${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`,
{ requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) }
);
}
try {
await withCaseLock(oldPath, newPath, async () => {
for (const s of open) await ctx.cleanupSession(s.id, true, 'case_rename');
if (moveFolder) await moveCaseFolder(oldPath, newPath);
try {
if (isLinked) {
const links = await readLinkedCases();
delete links[name];
links[newName] = newPath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
}
const existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (order || last) {
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(
{ ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) },
null,
2
)
);
}
} catch (err) {
// The folder moved but the registries did not follow: put it back rather than leave a case
// whose name and folder disagree.
if (moveFolder) await fs.rename(newPath, oldPath).catch(() => {});
throw err;
}
if (moveFolder) await moveClaudeHistory(oldPath, newPath);
});
ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder });
return {
success: true,
data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length },
};
} catch (err) {
if (err instanceof CaseRenameError) {
const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400;
return fail(status, ApiErrorCode.OPERATION_FAILED, err.message, { renameCode: err.code });
}
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
+15
View File
@@ -1992,6 +1992,21 @@ export const LinkCaseSchema = z.object({
path: safePathSchema,
});
/** POST /api/cases/:name/rename */
export const RenameCaseSchema = z
.object({
newName: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
/** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */
renameFolder: z.boolean().optional(),
/** Stop the live sessions that have the folder open, instead of refusing with the list of them. */
stopSessions: z.boolean().optional(),
})
.strict();
/** PUT /api/cases/order */
export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
+3
View File
@@ -458,6 +458,8 @@ export const CaseCreated = 'case:created' as const;
export const CaseLinked = 'case:linked' as const;
/** Case deleted or unlinked. */
export const CaseDeleted = 'case:deleted' as const;
/** Case renamed (name and folder). */
export const CaseRenamed = 'case:renamed' as const;
/** Case ordering changed. */
export const CaseOrderChanged = 'case:order-changed' as const;
@@ -702,6 +704,7 @@ export const SseEvent = {
CaseCreated,
CaseLinked,
CaseDeleted,
CaseRenamed,
CaseOrderChanged,
// Docker cases