mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
f1f7eec4d6
commit
0bd15ddc84
8 files changed
+350
-12
No files matched your search
@@ -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/<name>, so deleting the case deletes this working tree.</span>
|
||||
<span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, 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…</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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user