@kabmo-labs/kabmo 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +75 -70
  101. package/src/server/lib/updates/shell-release.ts +120 -136
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. package/dist/client/static/js/index-nzyjf92m.css +0 -1
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Branch naming and slugging for worktrees — ONE module, because the dialog and
8
+ * the create route must agree on what a name means. The dialog pre-fills the
9
+ * branch and derives the folder; the server re-derives both from what arrives.
10
+ *
11
+ * The word lists are OpenChamber's Ubuntu-style pairs (their
12
+ * `lib/git/branchNameGenerator.ts`), and the pick is seeded from
13
+ * `crypto.getRandomValues` rather than `Math.random`, which is available in the
14
+ * browser and in Bun without an import.
15
+ */
16
+
17
+ /**
18
+ * Kabmo's namespace for a generated worktree branch. Deliberately `wt`, not
19
+ * OpenChamber's `openchamber`: omp's own `/wt` already creates `wt/*`, so a
20
+ * repository that has used either tool reads one convention instead of two.
21
+ */
22
+ export const WORKTREE_BRANCH_PREFIX = 'wt';
23
+
24
+ const ADJECTIVES = [
25
+ 'artful', 'bionic', 'cosmic', 'disco', 'focal', 'groovy', 'jammy', 'kinetic',
26
+ 'lunar', 'noble', 'bold', 'brave', 'calm', 'eager', 'gentle', 'happy', 'keen',
27
+ 'lively', 'merry', 'swift', 'warm', 'wise', 'bright', 'clever', 'daring',
28
+ 'agile', 'crisp', 'fresh', 'lucid', 'quick', 'sharp', 'vivid', 'zealous',
29
+ ];
30
+
31
+ const NOUNS = [
32
+ 'aardvark', 'beaver', 'chipmunk', 'dolphin', 'falcon', 'gopher', 'hedgehog',
33
+ 'jackal', 'koala', 'lemur', 'mongoose', 'narwhal', 'otter', 'pangolin',
34
+ 'quokka', 'raccoon', 'salamander', 'toucan', 'walrus', 'yak', 'zebra',
35
+ 'badger', 'condor', 'dingo', 'egret', 'ferret', 'gecko', 'heron', 'iguana',
36
+ ];
37
+
38
+ /** A random index from the platform CSPRNG, so two sessions rarely collide. */
39
+ function pick(values: readonly string[]): string {
40
+ const bytes = new Uint8Array(2);
41
+ crypto.getRandomValues(bytes);
42
+ return values[((bytes[0] ?? 0) << 8 | (bytes[1] ?? 0)) % values.length] as string;
43
+ }
44
+
45
+ /** One adjective-noun pair, e.g. `cosmic-dolphin`. */
46
+ export function generateBranchSlug(): string {
47
+ return `${pick(ADJECTIVES)}-${pick(NOUNS)}`;
48
+ }
49
+
50
+ /** The default branch of a worktree: our namespace plus a generated slug. */
51
+ export function defaultWorktreeBranch(): string {
52
+ return `${WORKTREE_BRANCH_PREFIX}/${generateBranchSlug()}`;
53
+ }
54
+
55
+ /** The branch as git names it, without the ref namespace. */
56
+ export function normalizeBranchName(value: string): string {
57
+ return String(value ?? '')
58
+ .trim()
59
+ .replace(/^refs\/heads\//, '')
60
+ .replace(/^heads\//, '')
61
+ .replace(/\s+/g, '-')
62
+ .replace(/^\/+|\/+$/g, '');
63
+ }
64
+
65
+ /**
66
+ * One path segment, ≤80 chars, safe on every filesystem we target.
67
+ *
68
+ * A leading `.` is stripped and the dot-only results are rejected: `slug('.')`
69
+ * is `.` and `slug('..')` is `..`, and `path.join(root, name)` resolves those to
70
+ * the root itself and to its parent. A folder name that escapes its root is not
71
+ * a name.
72
+ */
73
+ export function slugWorktreeName(value: string): string {
74
+ const slug = String(value ?? '')
75
+ .trim()
76
+ .replace(/^refs\/heads\//, '')
77
+ .replace(/^heads\//, '')
78
+ .replace(/\s+/g, '-')
79
+ .replace(/^\/+|\/+$/g, '')
80
+ .split('/')
81
+ .join('-')
82
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
83
+ .replace(/-+/g, '-')
84
+ .replace(/^[.-]+|[.-]+$/g, '')
85
+ .slice(0, 80);
86
+ return slug === '.' || slug === '..' ? '' : slug;
87
+ }
88
+
89
+ /**
90
+ * The folder a branch gets. Our own `wt/` namespace is stripped — the folder
91
+ * does not repeat it — while a repository's own namespaces stay visible
92
+ * (`feat/x` → `feat-x`). A non-Latin branch (`测试分支`) leaves no slug, and the
93
+ * caller falls back to a generated name rather than an empty folder.
94
+ */
95
+ export function folderNameForBranch(branch: string): string {
96
+ const withoutRef = normalizeBranchName(branch);
97
+ const namespaced = `${WORKTREE_BRANCH_PREFIX}/`;
98
+ const withoutOwnPrefix = withoutRef.startsWith(namespaced)
99
+ ? withoutRef.slice(namespaced.length)
100
+ : withoutRef;
101
+ return slugWorktreeName(withoutOwnPrefix);
102
+ }
103
+
104
+ /**
105
+ * Whether git would plausibly accept this as a branch name. Rejects only what
106
+ * `git check-ref-format` rejects in every case — the server still asks git
107
+ * itself, because a plausible name can still collide with an existing ref.
108
+ */
109
+ export function isPlausibleBranchName(value: string): boolean {
110
+ if (!value) return false;
111
+ if (/^[.\-/]/.test(value) || /[/.]$/.test(value)) return false;
112
+ if (value.includes('..') || value.includes('//') || value.includes('@{')) return false;
113
+ if (/[\s~^:?*[\\]/.test(value)) return false;
114
+ return true;
115
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Worktree shapes shared by the server and the client.
8
+ *
9
+ * A worktree session is a normal session whose cwd is a linked checkout, so the
10
+ * only thing that crosses the wire is where it is and whether it is still there
11
+ * — everything else is read from git.
12
+ */
13
+
14
+ /** One row of `GET /api/worktrees`, and what a create answers with. */
15
+ export interface WorktreeInfo {
16
+ /** Absolute, canonical path of the checkout. */
17
+ path: string;
18
+ /** Branch checked out there (`refs/heads/` stripped); `''` when detached. */
19
+ branch: string;
20
+ /** The commit HEAD points at — what a detached row names instead of a branch. */
21
+ head: string;
22
+ detached: boolean;
23
+ /** git reports it prunable, or the directory is gone. */
24
+ missing: boolean;
25
+ /** The main checkout — listed, but never removable. */
26
+ isPrimary: boolean;
27
+ /** Changed + untracked count. */
28
+ dirty: number;
29
+ /**
30
+ * Commits on this branch that are not on the ref it was created from. Gates
31
+ * the delete dialog's branch checkbox, because `git branch -d` refuses an
32
+ * unmerged branch and a checkbox that cannot succeed is worse than none.
33
+ */
34
+ aheadOfStart: number;
35
+ /** The ref recorded when the worktree was created; `''` for an existing branch. */
36
+ startRef: string;
37
+ /** Chats whose cwd is inside this worktree. */
38
+ sessions: number;
39
+ }
40
+
41
+ /**
42
+ * The per-session view, carried on `SessionItemData` so every layout and every
43
+ * plugin reads one field instead of asking git.
44
+ */
45
+ export interface SessionWorktreeInfo {
46
+ branch: string;
47
+ /** Canonical worktree root. */
48
+ path: string;
49
+ /** The checkout this worktree belongs to — the folder's `project_path`. */
50
+ primaryRoot: string;
51
+ /** `missing` when git still tracks it but the directory is gone. */
52
+ status: 'ready' | 'missing';
53
+ detached: boolean;
54
+ }
55
+
56
+ /** Where a chat that has not started will run. */
57
+ export interface SpawnTarget {
58
+ folderId: number | null;
59
+ /** Absolute worktree path, or null for the folder's own checkout. */
60
+ worktreePath: string | null;
61
+ }
62
+
63
+ /** Why a worktree action was refused. Codes are stable; messages are not. */
64
+ export type WorktreeErrorCode =
65
+ | 'not_a_repo'
66
+ | 'no_commits'
67
+ | 'invalid_name'
68
+ | 'branch_exists'
69
+ | 'branch_in_use'
70
+ | 'path_exists'
71
+ | 'create_failed'
72
+ | 'not_a_worktree'
73
+ | 'primary_checkout'
74
+ | 'dirty'
75
+ | 'dirty_carryover'
76
+ | 'remove_failed'
77
+ | 'mock_mode';
78
+
79
+ /** One refusal, with the field it belongs to so a dialog can mark the input. */
80
+ export interface WorktreeFailure {
81
+ code: WorktreeErrorCode;
82
+ message: string;
83
+ field?: 'branch' | 'folder' | 'location';
84
+ /** Present for `dirty`: how many files would be lost. */
85
+ count?: number;
86
+ }
87
+
88
+ /** `GET /api/worktrees`. */
89
+ export interface WorktreeListPayload {
90
+ worktrees: WorktreeInfo[];
91
+ /** The resolved parent directory new worktrees are created in. */
92
+ base: string;
93
+ isRepo: boolean;
94
+ /** Present when the listing failed — never substituted with an empty list. */
95
+ error?: string;
96
+ }
97
+
98
+ /** `POST /api/worktrees/validate`. */
99
+ export interface WorktreeValidation {
100
+ ok: boolean;
101
+ failures: WorktreeFailure[];
102
+ /** The name and directory the create would use, so the dialog can show them. */
103
+ resolved?: { name: string; directory: string; branch: string };
104
+ }
@@ -74,4 +74,14 @@ export interface FsActionResponse {
74
74
  error?: string;
75
75
  /** The path a create or a transfer landed at, relative to the listed root. */
76
76
  path?: string;
77
+ /** What a create made, so the tree can draw the row it just added. */
78
+ kind?: 'file' | 'folder';
79
+ /**
80
+ * The path a rename moved FROM, relative to the listed root.
81
+ *
82
+ * A rename answers with both ends so the panel can move the row in place:
83
+ * re-reading every open folder to discover a path it was just told is the
84
+ * reload this field exists to avoid.
85
+ */
86
+ previousPath?: string;
77
87
  }
@@ -3,6 +3,8 @@
3
3
  * JSONL discovery and use the omp session UUID (string) as id — hence the
4
4
  * union with string ids. Mock/demo mode keeps numeric SQLite ids.
5
5
  */
6
+ import type { SessionWorktreeInfo } from '@/shared/lib/workspace/worktree/types';
7
+
6
8
  export interface SessionItemData {
7
9
  id: number | string;
8
10
  folder_id: number | string;
@@ -38,6 +40,18 @@ export interface SessionItemData {
38
40
  * saying so. Absent means the row is not waiting (the normal case).
39
41
  */
40
42
  waitingFor?: { waitId: string; sessionId: string; title: string | null };
43
+ /**
44
+ * The directory the session actually runs in. A worktree session's cwd is the
45
+ * linked checkout, not the folder's `project_path` — the difference the panels
46
+ * have to honour and the sidebar badge names.
47
+ */
48
+ cwd?: string;
49
+ /**
50
+ * The worktree this session runs in, when its cwd is a linked checkout.
51
+ * Absent for an ordinary session (the common case) and for a session whose cwd
52
+ * is outside every worktree of its project, which the row draws the same way.
53
+ */
54
+ worktree?: SessionWorktreeInfo;
41
55
  }
42
56
 
43
57
  /**
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The Files panel harness's fake disk, and the two writes a spec drives it with.
8
+ *
9
+ * A separate module because the harness file is at the repository's line
10
+ * ceiling, and because the disk is the part with its own rules: a create and a
11
+ * rename MUTATE it the way the real route mutates the filesystem, so the very
12
+ * next read agrees with the row the panel just drew. A stub that answered
13
+ * "created" while leaving the folder empty would make every local-edit spec pass
14
+ * against a tree the server would contradict.
15
+ */
16
+
17
+ /** The fake disk: `path -> child -> (null for a folder, 'file' for a file)`. */
18
+ export type Disk = Record<string, Record<string, null | 'file'> | undefined>;
19
+
20
+ /** One directory's own listing, created on demand. */
21
+ function listingOf(disk: Disk, path: string): Record<string, null | 'file'> {
22
+ disk[path] = disk[path] ?? {};
23
+ return disk[path]!;
24
+ }
25
+
26
+ /**
27
+ * Apply a create: every directory on the way exists afterwards, and the entry
28
+ * sits in its parent's listing. Returns the entry's path relative to the root.
29
+ */
30
+ export function writeToDisk(disk: Disk, folder: string, name: string, kind: 'file' | 'folder'): string {
31
+ const segments = name.split('/');
32
+ let parent = folder === '.' ? '' : folder;
33
+ for (const segment of segments.slice(0, -1)) {
34
+ listingOf(disk, parent || '.')[segment] = null;
35
+ parent = parent ? `${parent}/${segment}` : segment;
36
+ listingOf(disk, parent);
37
+ }
38
+ const leaf = segments[segments.length - 1];
39
+ listingOf(disk, parent || '.')[leaf] = kind === 'folder' ? null : 'file';
40
+ const created = parent ? `${parent}/${leaf}` : leaf;
41
+ if (kind === 'folder') listingOf(disk, created);
42
+ return created;
43
+ }
44
+
45
+ /** Split a relative path into its directory (`.` at the root) and its name. */
46
+ function splitPath(path: string): { dir: string; name: string } {
47
+ const cut = path.lastIndexOf('/');
48
+ return cut === -1 ? { dir: '.', name: path } : { dir: path.slice(0, cut), name: path.slice(cut + 1) };
49
+ }
50
+
51
+ /** Apply a rename: remove the old entry, write the new one, kind preserved. */
52
+ export function moveOnDisk(disk: Disk, fromPath: string, toPath: string): void {
53
+ const from = splitPath(fromPath);
54
+ const source = disk[from.dir];
55
+ const kind = source?.[from.name] === null ? 'folder' : 'file';
56
+ if (source) delete source[from.name];
57
+ const to = splitPath(toPath);
58
+ writeToDisk(disk, to.dir, to.name, kind);
59
+ }
60
+
61
+ /** Whether `path` already names an entry of the fake disk. */
62
+ export function diskHas(disk: Disk, path: string): boolean {
63
+ const { dir, name } = splitPath(path);
64
+ return (disk[dir] ?? {})[name] !== undefined;
65
+ }
66
+
67
+ /** One directory's entries as the server emits them: folders with `children: null`. */
68
+ export function entriesOf(disk: Disk, path: string): unknown[] {
69
+ const dir = disk[path] ?? {};
70
+ return Object.keys(dir).map((child) => {
71
+ const rel = path === '.' ? child : `${path}/${child}`;
72
+ return dir[child] === null
73
+ ? { id: rel, name: child, type: 'folder', path: rel, children: null, ignored: false }
74
+ : { id: rel, name: child, type: 'file', path: rel, ignored: false };
75
+ });
76
+ }
@@ -7,14 +7,11 @@
7
7
  * The Files panel's test harness: a fake disk, a real realtime server, and the
8
8
  * click helpers the panel's specs drive it with.
9
9
  *
10
- * Extracted because TWO spec files exercise the same panel — the listing
11
- * behaviours and the root context menu — and each was carrying its own copy of
12
- * the mount, the HTTP stub and the click helpers. A second copy is how the two
13
- * files drift into testing different setups.
14
- *
15
- * The HTTP stub is a real directory model: specs mutate `disk` between reads,
16
- * which is what makes a refresh assertion fail on a panel that answered from
17
- * its children cache instead of from the server.
10
+ * Extracted because several spec files exercise the same panel, and each was
11
+ * carrying its own copy of the mount, the HTTP stub and the helpers. The HTTP
12
+ * stub is a real directory model (see `file-explorer-disk.test-util`): specs
13
+ * mutate `disk` between reads, which is what makes a refresh assertion fail on a
14
+ * panel that answered from its children cache instead of from the server.
18
15
  */
19
16
 
20
17
  import { Window } from 'happy-dom';
@@ -29,14 +26,13 @@ import { startRealtimeTestServer, type RealtimeTestServer } from '@/test-support
29
26
  import { installDomGlobals, pristineWebSocket, restoreDomGlobals } from '@/test-support/pristine-globals';
30
27
  import type { TopicResolver } from '@/server/lib/realtime/hub.server';
31
28
  import type { GitStatusPayload } from '@/server/lib/fs/git-status-read';
29
+ import { diskHas, entriesOf, moveOnDisk, writeToDisk, type Disk } from '@/test-support/file-explorer-disk.test-util';
30
+
31
+ export type { Disk } from '@/test-support/file-explorer-disk.test-util';
32
32
 
33
33
  export const ROOT = '/ws';
34
34
  export const SESSION = 'new-files-panel';
35
35
  const TOPIC = fsTopic(`${ROOT}\u0000.`);
36
-
37
- /** The fake disk: `path -> child -> (null for a folder, 'file' for a file)`. */
38
- export type Disk = Record<string, Record<string, null | 'file'> | undefined>;
39
-
40
36
  const GIT_STATUS: GitStatusPayload = { changes: [], branch: 'main', branches: [], remoteBranches: [] };
41
37
 
42
38
  export interface FileExplorerHarness {
@@ -46,9 +42,9 @@ export interface FileExplorerHarness {
46
42
  dirReads: string[];
47
43
  /** Paths whose NEXT read answers a non-JSON 500 (a transport failure). */
48
44
  failNext: Set<string>;
49
- /** Every create the panel posted, as its form fields. */
45
+ /** Every create / rename / transfer the panel posted, as its form fields. */
50
46
  createRequests: Array<Record<string, string>>;
51
- /** Every duplicate/paste the panel posted, as its form fields. */
47
+ renameRequests: Array<Record<string, string>>;
52
48
  transferRequests: Array<Record<string, string>>;
53
49
  /** Files the panel asked the editor to open, in order. */
54
50
  openedFiles: Array<{ path: string; name: string }>;
@@ -66,44 +62,36 @@ export interface FileExplorerHarness {
66
62
  panelBackground: () => HTMLElement;
67
63
  /** Right-click the background, leaving its menu open. */
68
64
  openMenuOnBackground: () => Promise<void>;
69
- /** Right-click the background and click the menu's New File row. */
65
+ /** Right-click the background and click its New File / New Folder row. */
70
66
  openRootNewFile: () => Promise<void>;
67
+ openRootNewFolder: () => Promise<void>;
71
68
  /** Type a name into the open create dialog and submit it. */
72
69
  submitNewFile: (name: string) => Promise<void>;
70
+ /** Type a destination into the open rename dialog and submit it. */
71
+ submitRename: (path: string) => Promise<void>;
72
+ renameInput: () => HTMLInputElement | null;
73
+ /** Every row name the tree is currently drawing, in order. */
74
+ visibleNames: () => string[];
75
+ /** The whole rendered document's text — a dialog's inline refusal lives here. */
76
+ documentText: () => string;
73
77
  /** Every button label currently in the document — the menu assertions read it. */
74
78
  buttonLabels: () => string[];
75
79
  /** Click a button by its exact label (a context-menu row, a dialog action). */
76
80
  clickMenuRow: (label: string) => Promise<void>;
77
- /**
78
- * Drag the row named `from` onto the row named `to`, or onto the panel
79
- * background when `to` is null — the whole gesture, in the order the browser
80
- * raises it: dragstart on the source, dragover on the target (where a target
81
- * refuses), then drop.
82
- */
81
+ /** Drag `from` onto `to` (or the background when null): dragstart, dragover, drop. */
83
82
  dragRow: (from: string, to: string | null) => Promise<void>;
84
- /** Install the DOM globals (call from `beforeAll`). */
83
+ /** DOM globals for the whole file (call from beforeAll / afterAll). */
85
84
  install: () => void;
86
- /** Restore the runner's own globals (call from `afterAll`). */
87
85
  restore: () => void;
88
86
  }
89
87
 
90
- /** One directory's entries as the server emits them: folders with `children: null`. */
91
- function entriesOf(disk: Disk, path: string): unknown[] {
92
- const dir = disk[path] ?? {};
93
- return Object.keys(dir).map((child) => {
94
- const rel = path === '.' ? child : `${path}/${child}`;
95
- return dir[child] === null
96
- ? { id: rel, name: child, type: 'folder', path: rel, children: null, ignored: false }
97
- : { id: rel, name: child, type: 'file', path: rel, ignored: false };
98
- });
99
- }
100
-
101
88
  export function createFileExplorerHarness(): FileExplorerHarness {
102
89
  const harness: FileExplorerHarness = {
103
90
  disk: {},
104
91
  dirReads: [],
105
92
  failNext: new Set(),
106
93
  createRequests: [],
94
+ renameRequests: [],
107
95
  transferRequests: [],
108
96
  openedFiles: [],
109
97
  container: null as unknown as HTMLElement,
@@ -127,6 +115,7 @@ export function createFileExplorerHarness(): FileExplorerHarness {
127
115
  async mount() {
128
116
  harness.failNext = new Set();
129
117
  harness.createRequests = [];
118
+ harness.renameRequests = [];
130
119
  harness.transferRequests = [];
131
120
  harness.openedFiles = [];
132
121
  // A fresh mount is a fresh session: the panel's per-session UI state
@@ -144,8 +133,8 @@ export function createFileExplorerHarness(): FileExplorerHarness {
144
133
  target.WebSocket = pristineWebSocket;
145
134
  target.fetch = async (input: unknown, init?: RequestInit) => {
146
135
  const url = String(input);
147
- const json = (body: unknown) => new Response(JSON.stringify(body), {
148
- status: 200,
136
+ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
137
+ status,
149
138
  headers: { 'content-type': 'application/json' },
150
139
  });
151
140
  if (url.includes('/state')) return json({ sessionId: SESSION, state: {} });
@@ -161,11 +150,25 @@ export function createFileExplorerHarness(): FileExplorerHarness {
161
150
  harness.transferRequests.push(fields);
162
151
  return json({ success: true, path: `moved-${fields.actionType}` });
163
152
  }
153
+ if (fields.actionType === 'rename') {
154
+ harness.renameRequests.push(fields);
155
+ // The real route refuses an existing destination; the disk is the
156
+ // authority here too, so a refusal spec drives the same path.
157
+ const destination = fields.newPath;
158
+ if (destination !== fields.path && diskHas(harness.disk, destination)) {
159
+ return json({ error: `${destination} already exists` }, 409);
160
+ }
161
+ moveOnDisk(harness.disk, fields.path, destination);
162
+ return json({ success: true, path: destination, previousPath: fields.path });
163
+ }
164
164
  harness.createRequests.push(fields);
165
- // The create route answers with the new file's path relative to the
165
+ // The create route answers with the new entry's path relative to the
166
166
  // LISTED root; the folder is `.` for the panel's own background menu.
167
- const rel = fields.path === '.' ? fields.name : `${fields.path}/${fields.name}`;
168
- return json({ success: true, path: rel });
167
+ // `kind` rides back because the tree draws a folder differently from a
168
+ // file, and the request is the only place it is stated.
169
+ const kind = fields.kind === 'folder' ? 'folder' : 'file';
170
+ const rel = writeToDisk(harness.disk, fields.path, fields.name, kind);
171
+ return json({ success: true, kind, path: rel });
169
172
  }
170
173
  if (url.includes('/api/fs/dir')) {
171
174
  const path = new URL(url, 'http://127.0.0.1').searchParams.get('path') ?? '.';
@@ -241,8 +244,36 @@ export function createFileExplorerHarness(): FileExplorerHarness {
241
244
  await harness.openMenuOnBackground();
242
245
  await harness.clickMenuRow('New File');
243
246
  },
247
+ async openRootNewFolder() {
248
+ await harness.openMenuOnBackground();
249
+ await harness.clickMenuRow('New Folder');
250
+ },
251
+ async submitRename(path) {
252
+ const input = harness.renameInput();
253
+ if (!input) throw new Error('no rename dialog');
254
+ await act(async () => {
255
+ input.value = path;
256
+ input.dispatchEvent(new window.Event('input', { bubbles: true }));
257
+ });
258
+ for (let i = 0; i < 5; i += 1) await act(async () => {});
259
+ await harness.clickMenuRow('Rename');
260
+ for (let i = 0; i < 20; i += 1) await act(async () => {});
261
+ },
262
+ renameInput() {
263
+ return document.querySelector('input[aria-label^="Rename "]') as HTMLInputElement | null;
264
+ },
265
+ visibleNames() {
266
+ // A row's name span is the leaf that carries it; the row wrapper reads the
267
+ // same text, so the SPAN is what is collected (one per row).
268
+ return [...harness.container.querySelectorAll('span')]
269
+ .map((el) => el.textContent?.trim() ?? '')
270
+ .filter((text) => text.length > 0);
271
+ },
272
+ documentText() {
273
+ return document.body.textContent ?? '';
274
+ },
244
275
  async submitNewFile(name) {
245
- const input = document.querySelector('input[aria-label="New file name"]') as HTMLInputElement | null;
276
+ const input = document.querySelector('input[aria-label^="New "]') as HTMLInputElement | null;
246
277
  if (!input) throw new Error('no create dialog');
247
278
  await act(async () => {
248
279
  input.value = name;