From d2c75784d3da0ec3a37a368dca6d305b225de77c Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:22:34 +0800 Subject: [PATCH 1/2] fix(clone): treat Clone Repo's custom folder as a parent, like Create New --- docs/architecture-invariants.md | 2 +- docs/wiki/Core-Concepts.md | 2 +- src/web/public/index.html | 6 +-- src/web/public/session-ui.js | 16 ++++-- test/clone-custom-folder-ui.test.ts | 75 +++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 test/clone-custom-folder-ui.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 479d279b..9be87e00 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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 ''…`, 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 `/` 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). diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 1b2428a0..6f2cd0a0 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -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/` 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/` (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/` (or, with **Clone into a custom folder**, into `/`, 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 diff --git a/src/web/public/index.html b/src/web/public/index.html index 039d55f7..225913d4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3791,15 +3791,15 @@
- 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). + 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).
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index fc0d2582..2d097a7b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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/, 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; diff --git a/test/clone-custom-folder-ui.test.ts b/test/clone-custom-folder-ui.test.ts new file mode 100644 index 00000000..b763490f --- /dev/null +++ b/test/clone-custom-folder-ui.test.ts @@ -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 + * `/` exactly as Create New does, and without the checkbox no `path` is sent + * (the server then clones to `~/codeman-cases/`). 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) { + 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 / 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(); + }); +}); From 7594c7fc0b6cb3c4435f0b3274ced5b403373bb5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 05:20:37 +0100 Subject: [PATCH 2/2] fix(clone): keep the preview and the wording on the parent folder The "Will clone into" line named a folder the request would not use: the preflight replaces the page's guessed case name with the server's (for a URL such as .../repo?tab=readme the guess is repo-tab-readme and the server says repo) and nothing refreshed the preview, so it stayed on the old name while the field and the request moved on. It is now refreshed there too. The Browse picker's title and the empty-field toast still said "Choose the folder to clone into", the old exact-target meaning. Both now ask for the parent folder, matching the field's label. CLAUDE.md and the invariants paragraph split the two meanings: path on the API is the exact folder, the Clone Repo tab's field is a parent and the page sends /. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +- src/web/public/session-ui.js | 9 ++-- test/clone-custom-folder-ui.test.ts | 84 ++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f350fdb..5baaf86f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,7 +248,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) -**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) +**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (the API adds no case-name subfolder; the Clone Repo tab's custom folder is a PARENT, so the page sends `/` through `_cloneTargetPath()`, as Create New does through `_newCaseTargetPath()`) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) **Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `/.claude/worktrees/` on `worktree-`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9be87e00..9451eae8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -474,7 +474,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above) ### Clone a repository as a case -**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. +**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` is the folder the repository is cloned INTO, exactly as given (the API adds no case-name subfolder; the **Clone into a custom folder** field on the page is a parent, so the tab sends `/`, see UI below), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. - ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c ` makes git run an arbitrary command as its transport, and ANY `::` dispatches to a `git-remote-` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts. - ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. @@ -487,7 +487,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above) - **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): the **Clone into a custom folder** field is a PARENT folder, like Create New's: the tab sends `/` 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). +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), `test/clone-custom-folder-ui.test.ts` (the tab's parent folder, the request and the preview, against the real markup). Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`. diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2d097a7b..acebbf61 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4291,7 +4291,7 @@ Object.assign(CodemanApp.prototype, { openCloneCasePathPicker() { const input = document.getElementById('cloneCasePath'); PathPicker.open({ - title: 'Choose the folder to clone into', + title: 'Choose the parent folder for the clone', initialPath: input.value.trim(), directoriesOnly: true, onSelect: (path) => { @@ -4400,7 +4400,10 @@ Object.assign(CodemanApp.prototype, { // The server's suggestion wins over the local guess (it is the same function // 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; + 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) { @@ -4455,7 +4458,7 @@ Object.assign(CodemanApp.prototype, { const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked; if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) { - this.showToast('Choose the folder to clone into', 'error'); + this.showToast('Choose the parent folder for the clone', 'error'); return; } diff --git a/test/clone-custom-folder-ui.test.ts b/test/clone-custom-folder-ui.test.ts index b763490f..c3c0e495 100644 --- a/test/clone-custom-folder-ui.test.ts +++ b/test/clone-custom-folder-ui.test.ts @@ -2,7 +2,10 @@ * @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 * `/` exactly as Create New does, and without the checkbox no `path` is sent - * (the server then clones to `~/codeman-cases/`). Runs against the real index.html markup. + * (the server then clones to `~/codeman-cases/`). The "Will clone into" line has to name the + * folder the request will really use, so it follows every way the name or the folder can change: + * typing, the URL, the preflight's suggested name and the Browse picker. Runs against the real + * index.html markup. * * Port: none. */ @@ -33,7 +36,8 @@ function bootClone() { 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 }; + const preview = () => doc.getElementById('cloneCasePathPreview')?.textContent; + return { app, win, doc, set, preview, fetchMock }; } async function requestBody(fetchMock: ReturnType) { @@ -72,4 +76,80 @@ describe('Clone Repo custom folder', () => { const { body } = await requestBody(fetchMock); expect(body.path).toBeUndefined(); }); + + it.each([ + ['/mnt/projects', '/mnt/projects/bifrost'], + ['/mnt/projects///', '/mnt/projects/bifrost'], + [' /mnt/projects/ ', '/mnt/projects/bifrost'], + ['/', '/bifrost'], + ['~', '~/bifrost'], + ['~/', '~/bifrost'], + ])('joins the parent %j to the case name', (parent, target) => { + const { app, set } = bootClone(); + set('cloneCasePath', parent); + expect(app._cloneTargetPath()).toBe(target); + }); + + it('follows the name the preflight settles on, which is the one the request will carry', async () => { + const { app, doc, set, preview, fetchMock } = bootClone(); + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + set('cloneCasePath', '/mnt/projects'); + // The page's own guess for a URL copied out of a browser tab; the server reads the repo as `repo`. + set('cloneCaseName', 'repo-tab-readme'); + app.updateCloneCasePathPreview(); + expect(preview()).toBe('Will clone into: /mnt/projects/repo-tab-readme'); + app._applyClonePreflight( + { + parse: { cloneable: true, suggestedName: 'repo', provider: 'GitHub', owner: 'owner', repo: 'repo' }, + gitAvailable: true, + }, + 'https://github.com/owner/repo?tab=readme' + ); + expect(preview()).toBe('Will clone into: /mnt/projects/repo'); + await app.cloneCase(); + const { body } = await requestBody(fetchMock); + expect(body).toMatchObject({ name: 'repo', path: '/mnt/projects/repo' }); + }); + + it('sends the folder Browse picked as the parent, and titles the picker as the parent', async () => { + const { app, win, doc, preview, fetchMock } = bootClone(); + const open = vi.fn(); + (win as unknown as { PathPicker: { open: unknown } }).PathPicker = { open }; + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + app.openCloneCasePathPicker(); + const options = open.mock.calls[0]![0] as { title: string; directoriesOnly: boolean; onSelect(p: string): void }; + expect(options.title).toBe('Choose the parent folder for the clone'); + expect(options.directoriesOnly).toBe(true); + options.onSelect('/mnt/projects'); + expect((doc.getElementById('cloneCasePath') as HTMLInputElement).value).toBe('/mnt/projects'); + expect(preview()).toBe('Will clone into: /mnt/projects/bifrost'); + await app.cloneCase(); + const { body } = await requestBody(fetchMock); + expect(body.path).toBe('/mnt/projects/bifrost'); + }); + + it('asks for the parent folder before cloning when the checkbox is on and the field is empty', async () => { + const { app, doc, fetchMock } = bootClone(); + app.showToast = vi.fn(); + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + await app.cloneCase(); + expect(app.showToast).toHaveBeenCalledWith('Choose the parent folder for the clone', 'error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('builds no target and sends nothing without a case name', async () => { + const { app, doc, set, preview, fetchMock } = bootClone(); + app.showToast = vi.fn(); + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + set('cloneCasePath', '/mnt/projects'); + set('cloneCaseName', ''); + app.updateCloneCasePathPreview(); + expect(app._cloneTargetPath()).toBe(''); + expect(preview()).toBe( + 'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.' + ); + await app.cloneCase(); + expect(app.showToast).toHaveBeenCalledWith('Please enter a case name', 'error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); });