mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(cases): rename a case and its folder, with a lock and live-session handling
This commit is contained in:
1 parent
002de32a08
commit
ab0bc0aecd
14 files changed
+884
No files matched your search
@@ -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',
|
||||
|
||||
@@ -4957,6 +4957,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼</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)">✎</button>`
|
||||
}
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Delete case">✕</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;
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user