From 201603e57b06a913bfd09817bf6b44a78a21aeeb Mon Sep 17 00:00:00 2001
From: Devvyn <22340871+opticon454@users.noreply.github.com>
Date: Wed, 21 Oct 2026 14:38:34 +0800
Subject: [PATCH] feat(cases): rename in the app's own dialog instead of
browser prompts
---
src/web/public/index.html | 29 ++++++
src/web/public/session-ui.js | 99 +++++++++++++------
test/case-rename-ui.test.ts | 182 ++++++++++++++++++++++++++---------
3 files changed, 237 insertions(+), 73 deletions(-)
diff --git a/src/web/public/index.html b/src/web/public/index.html
index f3a2b2a2..a471fa3a 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -3792,6 +3792,35 @@
+
+
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index ecd82e3c..d74a5371 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -4996,48 +4996,86 @@ Object.assign(CodemanApp.prototype, {
},
/**
- * 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.
+ * Rename a case and its folder (POST /api/cases/:name/rename), in the app's own dialog.
+ *
+ * A linked case shows a checkbox for renaming the real folder too (unticked = the name only). When live
+ * sessions have the folder open the server refuses and lists them; the dialog then lists them and its button
+ * becomes "Stop sessions and rename", so they are only stopped after a second, explicit press. Errors from
+ * the server (a taken name, a folder another program has open) stay inside the dialog so the name can be fixed.
*/
- async renameCase(name) {
+ 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;
+ this._renameCaseState = { name, linked: Boolean(info?.linked), stop: false, busy: false };
+ const input = document.getElementById('renameCaseInput');
+ const label = document.getElementById('renameCaseLabel');
+ if (label) label.textContent = `New name for "${name}"`;
+ if (input) input.value = name;
+ const row = document.getElementById('renameCaseFolderRow');
+ row?.classList.toggle('hidden', !info?.linked);
+ const box = document.getElementById('renameCaseFolder');
+ if (box) box.checked = false;
+ const hint = document.getElementById('renameCaseFolderHint');
+ if (hint) hint.textContent = info?.linked ? `${info.path} (unticked: only the name in this list changes)` : '';
+ document.getElementById('renameCaseNote')?.classList.toggle('hidden', Boolean(info?.linked));
+ this._renameCaseNotice('');
+ const submit = document.getElementById('renameCaseSubmit');
+ if (submit) submit.textContent = 'Rename';
+ document.getElementById('renameCaseModal')?.classList.add('active');
+ input?.focus?.();
+ input?.select?.();
+ },
+
+ closeRenameCase() {
+ document.getElementById('renameCaseModal')?.classList.remove('active');
+ this._renameCaseState = null;
+ },
+
+ /** Show (or clear, with '') a message inside the rename dialog. */
+ _renameCaseNotice(message) {
+ const el = document.getElementById('renameCaseError');
+ if (!el) return;
+ el.textContent = message;
+ el.classList.toggle('hidden', !message);
+ },
+
+ async submitRenameCase() {
+ const state = this._renameCaseState;
+ if (!state || state.busy) return;
+ const name = state.name;
+ const newName = (document.getElementById('renameCaseInput')?.value || '').trim();
+ if (!newName || newName === name) {
+ this._renameCaseNotice('Enter a different name.');
+ return;
+ }
if (!/^[a-zA-Z0-9_-]+$/.test(newName)) {
- this.showToast('Use only letters, numbers, hyphens and underscores', 'error');
+ this._renameCaseNotice('Use only letters, numbers, hyphens and underscores.');
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 () => {
+ if (state.linked) body.renameFolder = Boolean(document.getElementById('renameCaseFolder')?.checked);
+ if (state.stop) body.stopSessions = true;
+ state.busy = true;
+ const submit = document.getElementById('renameCaseSubmit');
+ if (submit) submit.disabled = true;
+ try {
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();
+ const data = await res.json().catch(() => ({ success: false, error: 'Unexpected response' }));
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');
+ const list = (data.sessions || []).map((s) => `${s.name}`).join(', ');
+ state.stop = true;
+ this._renameCaseNotice(`These sessions have the folder open: ${list}. They will be stopped (nothing is restarted) before the folder is renamed.`);
+ if (submit) submit.textContent = 'Stop sessions and rename';
return;
}
+ if (!data.success) {
+ this._renameCaseNotice(data.error || 'Failed to rename the case.');
+ return;
+ }
+ this.closeRenameCase();
this.showToast(`Case renamed to "${newName}"`, 'success');
// The per-case run options are stored under the case name in this browser.
try {
@@ -5052,7 +5090,10 @@ Object.assign(CodemanApp.prototype, {
await this.loadQuickStartCases(current === name ? newName : current);
this.renderCaseManageList();
} catch (err) {
- this.showToast('Failed to rename case: ' + err.message, 'error');
+ this._renameCaseNotice('Failed to rename the case: ' + err.message);
+ } finally {
+ state.busy = false;
+ if (submit) submit.disabled = false;
}
},
diff --git a/test/case-rename-ui.test.ts b/test/case-rename-ui.test.ts
index 2b250395..8de64dca 100644
--- a/test/case-rename-ui.test.ts
+++ b/test/case-rename-ui.test.ts
@@ -1,26 +1,74 @@
/**
- * @fileoverview The Rename button's flow in session-ui.js (renameCase): the prompts, the second confirmation
- * before sessions are stopped, and the follow-up refresh. Evaluates the real file against a stub app.
+ * @fileoverview The Rename dialog's flow in session-ui.js (renameCase / submitRenameCase): it is the app's
+ * own modal, not a browser prompt; a linked case offers the folder checkbox; live sessions are listed inside
+ * the dialog and only stopped after a second press; server errors stay in the dialog. Evaluates the real file
+ * against a stub DOM.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
+const HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
-function makeApp(opts: { prompt: string | null; confirms: boolean[]; responses: unknown[]; cases: unknown[] }) {
+type El = {
+ value: string;
+ textContent: string;
+ checked: boolean;
+ disabled: boolean;
+ classes: Set
;
+ classList: {
+ add(c: string): void;
+ remove(c: string): void;
+ toggle(c: string, on?: boolean): void;
+ contains(c: string): boolean;
+ };
+ focus(): void;
+ select(): void;
+};
+const el = (hidden = false): El => {
+ const classes = new Set(hidden ? ['hidden'] : []);
+ return {
+ value: '',
+ textContent: '',
+ checked: false,
+ disabled: false,
+ classes,
+ classList: {
+ add: (c) => void classes.add(c),
+ remove: (c) => void classes.delete(c),
+ toggle: (c, on) => void ((on ?? !classes.has(c)) ? classes.add(c) : classes.delete(c)),
+ contains: (c) => classes.has(c),
+ },
+ focus() {},
+ select() {},
+ };
+};
+
+function makeApp(opts: { responses: unknown[]; cases: unknown[]; current?: string }) {
const calls: Array<{ url: string; body: Record }> = [];
- const confirmMessages: string[] = [];
const toasts: string[] = [];
const store = new Map([['caseSettings_alpha', '{"agentTeams":true}']]);
const queue = [...opts.responses];
+ const ids = [
+ 'renameCaseModal',
+ 'renameCaseInput',
+ 'renameCaseLabel',
+ 'renameCaseFolderRow',
+ 'renameCaseFolder',
+ 'renameCaseFolderHint',
+ 'renameCaseNote',
+ 'renameCaseError',
+ 'renameCaseSubmit',
+ 'quickStartCase',
+ ];
+ const els: Record = Object.fromEntries(ids.map((id) => [id, el()]));
+ els.quickStartCase.value = opts.current ?? 'alpha';
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
- document: { getElementById: () => ({ value: 'alpha' }) },
- prompt: () => opts.prompt,
- confirm: (m: string) => (confirmMessages.push(m), opts.confirms.shift() ?? false),
+ document: { getElementById: (id: string) => els[id] ?? null },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
@@ -41,69 +89,115 @@ function makeApp(opts: { prompt: string | null; confirms: boolean[]; responses:
loadQuickStartCases: async (n: unknown) => void refreshed.push(n),
renderCaseManageList: () => void refreshed.push('render'),
});
- return { app, calls, confirmMessages, toasts, store, refreshed };
+ return { app, els, calls, toasts, store, refreshed };
}
const ok = { success: true, data: {} };
-describe('renameCase (case manager)', () => {
- it('renames a local case, moves its run options and reselects it under the new name', async () => {
- const t = makeApp({
- prompt: ' beta ',
- confirms: [],
- responses: [ok],
- cases: [{ name: 'alpha', location: 'local' }],
- });
- await t.app.renameCase('alpha');
+describe('the Rename dialog is part of the page, not a browser prompt', () => {
+ it('has its modal in index.html, with the name, folder and error elements the code drives', () => {
+ for (const id of [
+ 'renameCaseModal',
+ 'renameCaseInput',
+ 'renameCaseFolder',
+ 'renameCaseError',
+ 'renameCaseSubmit',
+ ]) {
+ expect(HTML).toContain(`id="${id}"`);
+ }
+ const fn = SRC.slice(SRC.indexOf('renameCase(name)'), SRC.indexOf('async deleteCase(name)'));
+ expect(fn).not.toMatch(/\b(prompt|confirm|alert)\(/);
+ });
+});
+
+describe('renameCase / submitRenameCase', () => {
+ it('opens pre-filled, renames a local case, moves its run options and reselects it under the new name', async () => {
+ const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', location: 'local' }] });
+ t.app.renameCase('alpha');
+ expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
+ expect(t.els.renameCaseInput.value).toBe('alpha');
+ expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(true);
+
+ t.els.renameCaseInput.value = ' beta ';
+ await t.app.submitRenameCase();
+
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
+ expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
expect(t.store.has('caseSettings_alpha')).toBe(false);
expect(t.refreshed).toEqual(['beta', 'render']);
});
- it('does nothing for a cancelled prompt, the same name, or a name that is not allowed', async () => {
- for (const entered of [null, '', 'alpha', 'a b', '../x']) {
- const t = makeApp({ prompt: entered, confirms: [], responses: [], cases: [{ name: 'alpha' }] });
- await t.app.renameCase('alpha');
- expect(t.calls, String(entered)).toEqual([]);
+ it('keeps the dialog open with a message for the same name or a name that is not allowed, and sends nothing', async () => {
+ for (const entered of ['', 'alpha', 'a b', '../x']) {
+ const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
+ t.app.renameCase('alpha');
+ t.els.renameCaseInput.value = entered;
+ await t.app.submitRenameCase();
+ expect(t.calls, entered).toEqual([]);
+ expect(t.els.renameCaseError.classes.has('hidden'), entered).toBe(false);
+ expect(t.els.renameCaseModal.classes.has('active'), entered).toBe(true);
}
});
- it('asks a linked case whether to rename the real folder, and sends the answer', async () => {
- const t = makeApp({
- prompt: 'beta',
- confirms: [true],
- responses: [ok],
- cases: [{ name: 'alpha', linked: true, path: '/srv/app' }],
- });
- await t.app.renameCase('alpha');
- expect(t.confirmMessages[0]).toContain('/srv/app');
+ it('shows the folder checkbox for a linked case and sends its state', async () => {
+ const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
+ t.app.renameCase('alpha');
+ expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(false);
+ expect(t.els.renameCaseFolderHint.textContent).toContain('/srv/app');
+ t.els.renameCaseInput.value = 'beta';
+ t.els.renameCaseFolder.checked = true;
+ await t.app.submitRenameCase();
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
+
+ const alias = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
+ alias.app.renameCase('alpha');
+ alias.els.renameCaseInput.value = 'beta';
+ await alias.app.submitRenameCase();
+ expect(alias.calls[0].body).toEqual({ newName: 'beta', renameFolder: false });
});
- it('lists the live sessions and only stops them after a second confirmation', async () => {
+ it('lists the live sessions in the dialog and stops them only on the second press', async () => {
const refusal = {
success: false,
error: '1 live session has this folder open.',
requiresStop: true,
sessions: [{ id: 's', name: 'w1-alpha' }],
};
- const t = makeApp({ prompt: 'beta', confirms: [true], responses: [refusal, ok], cases: [{ name: 'alpha' }] });
- await t.app.renameCase('alpha');
- expect(t.confirmMessages[0]).toContain('w1-alpha');
- expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
+ const t = makeApp({ responses: [refusal, ok], cases: [{ name: 'alpha' }] });
+ t.app.renameCase('alpha');
+ t.els.renameCaseInput.value = 'beta';
- const declined = makeApp({ prompt: 'beta', confirms: [false], responses: [refusal], cases: [{ name: 'alpha' }] });
- await declined.app.renameCase('alpha');
- expect(declined.calls).toHaveLength(1);
- expect(declined.refreshed).toEqual([]);
+ await t.app.submitRenameCase();
+ expect(t.els.renameCaseError.textContent).toContain('w1-alpha');
+ expect(t.els.renameCaseSubmit.textContent).toBe('Stop sessions and rename');
+ expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
+ expect(t.calls).toHaveLength(1);
+
+ await t.app.submitRenameCase();
+ expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
+ expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
});
- it('shows the server’s message when the folder is locked, and leaves the lists alone', async () => {
+ it('cancelling the dialog stops nothing and renames nothing', async () => {
+ const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
+ t.app.renameCase('alpha');
+ t.app.closeRenameCase();
+ expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
+ await t.app.submitRenameCase(); // nothing open: ignored
+ expect(t.calls).toEqual([]);
+ });
+
+ it('shows the server’s message inside the dialog when the folder is locked, and leaves the lists alone', async () => {
const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' };
- const t = makeApp({ prompt: 'beta', confirms: [], responses: [locked], cases: [{ name: 'alpha' }] });
- await t.app.renameCase('alpha');
- expect(t.toasts).toEqual(['The folder is in use by another program (EBUSY).']);
+ const t = makeApp({ responses: [locked], cases: [{ name: 'alpha' }] });
+ t.app.renameCase('alpha');
+ t.els.renameCaseInput.value = 'beta';
+ await t.app.submitRenameCase();
+ expect(t.els.renameCaseError.textContent).toBe('The folder is in use by another program (EBUSY).');
+ expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
+ expect(t.els.renameCaseSubmit.disabled).toBe(false); // the name can be fixed and tried again
+ expect(t.toasts).toEqual([]);
expect(t.refreshed).toEqual([]);
expect(t.store.has('caseSettings_alpha')).toBe(true);
});