mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(clone): treat Clone Repo's custom folder as a parent, like Create New
This commit is contained in:
1 parent
46b85fa6d0
commit
d2c75784d3
5 files changed
+91
-10
No files matched your search
@@ -485,7 +485,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
|
||||
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
|
||||
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
|
||||
- **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary.
|
||||
- **UI** (`case-clone` tab in the Add Case modal): debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
|
||||
- **UI** (`case-clone` tab in the Add Case modal): the **Clone into a custom folder** field is a PARENT folder, like Create New's: the tab sends `<folder>/<case name>` as `path` (`_cloneTargetPath()`), so the API's `path` stays the exact target; debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
|
||||
|
||||
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint).
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Three ways to get one, all under **New or link a case…** at the bottom of the
|
||||
| How | Result |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or, with **Clone into a custom folder**, into `<parent folder you choose>/<name>`, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
Linked cases keep living where they are. Deleting a case in Codeman removes the
|
||||
|
||||
@@ -3791,15 +3791,15 @@
|
||||
</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 a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
|
||||
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder instead and the repository is cloned into a subfolder of it named after the case, the same way it goes under ~/codeman-cases; 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>Folder</label>
|
||||
<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 an empty folder, or one that does not exist yet, to clone into.</span>
|
||||
<span class="form-hint" id="cloneCasePathPreview">Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Branch or Tag (optional)</label>
|
||||
|
||||
@@ -4264,24 +4264,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
const hint = document.getElementById('cloneCaseNameHint');
|
||||
if (hint) {
|
||||
hint.textContent = custom.checked
|
||||
? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.'
|
||||
? 'Filled in from the URL. Names the case and the subfolder of the chosen folder the repository is cloned into.'
|
||||
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
|
||||
}
|
||||
this.updateCloneCasePathPreview();
|
||||
},
|
||||
|
||||
/** The folder the repository is cloned into: exactly what the field holds (no case name is added). */
|
||||
/** The folder the repository is cloned into: the parent field plus the case name, as Create New does. */
|
||||
_cloneTargetPath() {
|
||||
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
|
||||
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 raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
|
||||
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 an empty folder, or one that does not exist yet, to clone into.';
|
||||
hint.textContent = target
|
||||
? `Will clone into: ${target}`
|
||||
: 'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.';
|
||||
},
|
||||
|
||||
openCloneCasePathPicker() {
|
||||
@@ -4334,12 +4338,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;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Frontend test for Clone Repo's custom folder (session-ui.js `cloneCase()` /
|
||||
* `_cloneTargetPath()`): the chosen folder is the PARENT, so the clone request carries
|
||||
* `<folder>/<case name>` exactly as Create New does, and without the checkbox no `path` is sent
|
||||
* (the server then clones to `~/codeman-cases/<name>`). Runs against the real index.html markup.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
|
||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function bootClone() {
|
||||
const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
|
||||
const run = (src: string) => (win as unknown as { eval: (s: string) => void }).eval(src);
|
||||
run('window.CodemanApp = function CodemanApp() {};');
|
||||
run(CONSTANTS_JS);
|
||||
run(SESSION_UI_JS);
|
||||
const app = new win.CodemanApp();
|
||||
app.showToast = () => {};
|
||||
app._setCloneStatus = () => {};
|
||||
app.closeCreateCaseModal = () => {};
|
||||
app.loadQuickStartCases = async () => {};
|
||||
app.saveLastUsedCase = async () => {};
|
||||
const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: {} }) }));
|
||||
(win as unknown as { fetch: unknown }).fetch = fetchMock;
|
||||
const doc = win.document;
|
||||
const set = (id: string, value: string) => ((doc.getElementById(id) as HTMLInputElement).value = value);
|
||||
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
|
||||
set('cloneCaseName', 'bifrost');
|
||||
return { app, doc, set, fetchMock };
|
||||
}
|
||||
|
||||
async function requestBody(fetchMock: ReturnType<typeof vi.fn>) {
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { body: string }];
|
||||
return { url, body: JSON.parse(init.body) };
|
||||
}
|
||||
|
||||
describe('Clone Repo custom folder', () => {
|
||||
it('sends <folder>/<case name> as the path and previews it', async () => {
|
||||
const { app, doc, set, fetchMock } = bootClone();
|
||||
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
|
||||
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454/');
|
||||
app.updateCloneCasePathPreview();
|
||||
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
|
||||
'Will clone into: /mnt/user/Scripts/GitHub/opticon454/bifrost'
|
||||
);
|
||||
await app.cloneCase();
|
||||
const { url, body } = await requestBody(fetchMock);
|
||||
expect(url).toBe('/api/cases/clone');
|
||||
expect(body.path).toBe('/mnt/user/Scripts/GitHub/opticon454/bifrost');
|
||||
expect(body.name).toBe('bifrost');
|
||||
});
|
||||
|
||||
it('follows the case name when it is edited', () => {
|
||||
const { app, doc, set } = bootClone();
|
||||
set('cloneCasePath', '~/projects');
|
||||
set('cloneCaseName', 'other');
|
||||
app.onCloneNameEdited();
|
||||
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: ~/projects/other');
|
||||
});
|
||||
|
||||
it('sends no path without the checkbox, even when the folder field still holds one', async () => {
|
||||
const { app, set, fetchMock } = bootClone();
|
||||
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454');
|
||||
await app.cloneCase();
|
||||
const { body } = await requestBody(fetchMock);
|
||||
expect(body.path).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user