feat(cases): clone a repository into a custom folder

Clone Repo could only clone under ~/codeman-cases. POST /api/cases/clone now takes an optional path (parent folder plus case name) and the Clone tab gets a Clone into a custom folder option with a Browse button, mirroring Create in a custom folder. The path is judged by prepareNewCasePath, the clone is registered as a linked case, the option is admin-only in multi-user mode, and an existing target folder is refused.
This commit is contained in:
Devvyn committed 2026-10-31 16:19:20 +08:00
1 parent f1f7eec4d6
commit 0bd15ddc84
8 files changed
+350 -12

No files matched your search

+13 -1
View File
@@ -3654,7 +3654,19 @@
<div class="form-row">
<label>Case Name</label>
<input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()">
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/&lt;name&gt;, so deleting the case deletes this working tree.</span>
<span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/&lt;name&gt;, so deleting the case deletes this working tree.</span>
</div>
<div class="form-row" id="cloneCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose another folder and it is cloned there instead; it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
</div>
<div class="form-row" id="cloneCustomPathRow" style="display:none">
<label>Parent Folder</label>
<div class="path-input-group">
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="cloneCasePathPreview">Pick the folder the clone should be created inside.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
+71 -1
View File
@@ -4097,6 +4097,15 @@ Object.assign(CodemanApp.prototype, {
set('cloneRepoUrl', '');
set('cloneCaseName', '');
set('cloneRepoRef', '');
set('cloneCasePath', '');
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
// server refuses it (it writes outside the cases directory and into the shared registry).
const customToggle = document.getElementById('cloneCustomPathToggle');
if (customToggle) customToggle.checked = false;
const customRow = document.getElementById('cloneCustomPathToggleRow');
const me = window.__codemanUser || {};
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
this.toggleCloneCustomPath();
const shallow = document.getElementById('cloneShallow');
if (shallow) shallow.checked = false;
const start = document.getElementById('cloneStartSession');
@@ -4124,6 +4133,52 @@ Object.assign(CodemanApp.prototype, {
}
},
/** Custom-folder row for Clone Repo: shows or hides the parent-folder field and rewords the name hint. */
toggleCloneCustomPath() {
const custom = document.getElementById('cloneCustomPathToggle');
const row = document.getElementById('cloneCustomPathRow');
if (!custom || !row) return;
row.style.display = custom.checked ? '' : 'none';
const hint = document.getElementById('cloneCaseNameHint');
if (hint) {
hint.textContent = custom.checked
? 'Filled in from the URL. Cloned into a folder of this name inside the parent folder below.'
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
}
this.updateCloneCasePathPreview();
},
/** The folder the clone would be created in: the parent field plus the case name. */
_cloneTargetPath() {
const rawParent = (document.getElementById('cloneCasePath')?.value || '').trim();
const name = (document.getElementById('cloneCaseName')?.value || '').trim();
if (!rawParent || !name) return '';
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
return `${rawParent.replace(/\/+$/, '')}/${name}`;
},
updateCloneCasePathPreview() {
const hint = document.getElementById('cloneCasePathPreview');
if (!hint) return;
const target = this._cloneTargetPath();
hint.textContent = target ? `Will clone into: ${target}` : 'Pick the folder the clone should be created inside.';
},
openCloneCasePathPicker() {
const input = document.getElementById('cloneCasePath');
PathPicker.open({
title: 'Choose the folder to clone into',
initialPath: input.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
input.value = path;
this.updateCloneCasePathPreview();
input.focus();
input.setSelectionRange(path.length, path.length);
},
});
},
_setCloneStatus(message, kind) {
const el = document.getElementById('cloneRepoStatus');
if (!el) return;
@@ -4159,12 +4214,14 @@ Object.assign(CodemanApp.prototype, {
onCloneNameEdited() {
// Once the user types a name, autofill stops fighting them.
this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim();
this.updateCloneCasePathPreview();
},
onCloneUrlInput() {
const url = document.getElementById('cloneRepoUrl')?.value.trim() || '';
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url);
this.updateCloneCasePathPreview();
clearTimeout(this._clonePreflightTimer);
this._clonePreflightAbort?.abort();
this._clonePreflightAbort = null;
@@ -4220,6 +4277,7 @@ Object.assign(CodemanApp.prototype, {
// the case name is validated against), but never over a name the user typed.
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName;
this.updateCloneCasePathPreview();
const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`;
if (data.gitAvailable === false) {
@@ -4272,6 +4330,12 @@ Object.assign(CodemanApp.prototype, {
return;
}
const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked;
if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) {
this.showToast('Choose the folder to clone into', 'error');
return;
}
this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, '');
try {
const res = await fetch('/api/cases/clone', {
@@ -4279,7 +4343,13 @@ Object.assign(CodemanApp.prototype, {
headers: { 'Content-Type': 'application/json' },
// Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one
// on the wire — omit the empty fields instead of sending null.
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
body: JSON.stringify({
name,
repository: url,
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
...(customFolder ? { path: this._cloneTargetPath() } : {}),
}),
});
const data = await res.json();
if (!data.success) {
+88 -7
View File
@@ -686,7 +686,7 @@ export function registerCaseRoutes(
warnings: string[];
}>
> => {
const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body);
const { name, repository, ref, shallow, description, path: customPath } = parseBody(CloneCaseSchema, req.body);
const user = getAuthUser(req);
const parsed = parseGitRepositoryUrl(repository);
@@ -705,8 +705,63 @@ export function registerCaseRoutes(
);
}
// A custom folder writes outside the cases directory and registers the path in the shared,
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
if (customPath !== undefined) {
const denied = adminOnly(req, reply);
if (denied) return denied;
}
const casesDir = resolveCasesDir(user);
const casePath = validatePathWithinBase(name, casesDir);
let casePath: string | null;
if (customPath !== undefined) {
const prepared = await prepareNewCasePath(customPath, {
home: homedir(),
dataDir: getDataDir(),
casesDirs: [...new Set([casesDir, resolveCasesDir()])],
});
if (!prepared.ok) {
reply.code(
prepared.code === 'UNREACHABLE'
? 422
: prepared.code === 'NOT_FOUND'
? 404
: prepared.code === 'EXISTS'
? 409
: 400
);
return createErrorResponse(
prepared.code === 'UNREACHABLE'
? ApiErrorCode.OPERATION_FAILED
: prepared.code === 'NOT_FOUND'
? ApiErrorCode.NOT_FOUND
: prepared.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT,
prepared.reason
);
}
// git clones into a fresh folder it creates itself; an empty existing one is refused rather than
// removed and re-made (the removal could race another writer).
if (prepared.existedEmpty) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`${prepared.path} already exists. Pick a parent folder where the case folder does not exist yet.`
);
}
casePath = prepared.path;
// The same folder listed as a local case in the cases dir would make deleting it remove files.
if (existsSync(join(casesDir, name))) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
'A case with this name already exists in codeman-cases.'
);
}
} else {
casePath = validatePathWithinBase(name, casesDir);
}
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
// Reject a duplicate name across EVERY case kind before invoking git, so a
@@ -715,6 +770,11 @@ export function registerCaseRoutes(
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const linkedAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
if (linkedAs && customPath !== undefined) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${linkedAs}"`);
}
if (
existsSync(casePath) ||
linkedCases[name] ||
@@ -724,11 +784,14 @@ export function registerCaseRoutes(
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// git creates the leaf, not necessarily the case space above it.
try {
mkdirSync(casesDir, { recursive: true });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
// git creates the leaf, not necessarily the case space above it (a custom folder's parent
// already exists: prepareNewCasePath refused a missing one).
if (customPath === undefined) {
try {
mkdirSync(casesDir, { recursive: true });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
const clone = await cloneRepository({
@@ -794,6 +857,24 @@ export function registerCaseRoutes(
warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`);
}
if (customPath !== undefined) {
// Re-read right before writing so a case linked while git ran is not dropped. Like
// POST /api/cases/link the write is not serialized, so this only narrows the window.
try {
const fresh = await readLinkedCases();
if (fresh[name]) throw new Error(`Case "${name}" was linked while the clone ran`);
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
} catch (err) {
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
reply.code(500);
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
);
}
}
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return {
success: true,
+6
View File
@@ -785,6 +785,12 @@ export const CloneCaseSchema = z.object({
/** `--depth 1`. */
shallow: z.boolean().optional(),
description: z.string().max(1000).optional(),
/**
* Clone into this exact folder (parent + case name) instead of under the cases directory, and
* register it as a linked case. Absolute or starting with `~`; only length-bounded here, since
* `prepareNewCasePath()` (web/case-path.ts) judges it and produces the user-facing reason.
*/
path: z.string().min(1).max(1000).optional(),
});
/** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */