@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
@@ -6,12 +6,12 @@
6
6
  /**
7
7
  * The Files panel's "New file" branch.
8
8
  *
9
- * Two rules are load-bearing and neither is visible from the happy path: the
10
- * name may not carry a path separator (the folder is named by `path`, and a
11
- * separator would turn a name into an arbitrary write under the root), and an
12
- * existing file is REFUSED rather than truncated. The second one is the one a
13
- * naive `Bun.write` gets wrong: `Bun.write` creates or truncates, so a name
14
- * that collides with a real file would silently blank it.
9
+ * Two rules are load-bearing and neither is visible from the happy path. The
10
+ * name may carry SEGMENTS (that is the nested-path grammar, and its refusals
11
+ * live in `action-nested.test.ts`), and an existing file is REFUSED rather
12
+ * than truncated — the second is the one a naive `Bun.write` gets wrong:
13
+ * `Bun.write` creates or truncates, so a name that collides with a real file
14
+ * would silently blank it.
15
15
  */
16
16
 
17
17
  import { afterEach, describe, expect, test } from 'bun:test';
@@ -34,7 +34,10 @@ async function create(folderPath: string, name: string) {
34
34
  form.append('path', folderPath);
35
35
  form.append('name', name);
36
36
  const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
37
- return { status: res.status, body: (await res.json()) as { success?: boolean; path?: string; error?: string } };
37
+ return {
38
+ status: res.status,
39
+ body: (await res.json()) as { success?: boolean; kind?: string; path?: string; error?: string },
40
+ };
38
41
  }
39
42
 
40
43
  describe('fs create', () => {
@@ -43,7 +46,7 @@ describe('fs create', () => {
43
46
  const { status, body } = await create('tmp-create-probe', 'notes.md');
44
47
 
45
48
  expect(status).toBe(200);
46
- expect(body).toEqual({ success: true, path: 'tmp-create-probe/notes.md' });
49
+ expect(body).toEqual({ success: true, kind: 'file', path: 'tmp-create-probe/notes.md' });
47
50
  expect(fs.readFileSync(path.join(DIR, 'notes.md'), 'utf8')).toBe('');
48
51
  });
49
52
 
@@ -67,12 +70,14 @@ describe('fs create', () => {
67
70
  expect(fs.readFileSync(path.join(DIR, 'keep.txt'), 'utf8')).toBe('important');
68
71
  });
69
72
 
70
- test('refuses a name carrying a path separator', async () => {
73
+ test('a path separator is a NESTED path, and `..` is still refused', async () => {
71
74
  fs.mkdirSync(DIR, { recursive: true });
72
- for (const name of ['../escape.txt', 'sub/escape.txt', 'sub\\escape.txt']) {
73
- const { status } = await create('tmp-create-probe', name);
74
- expect(status).toBe(400);
75
- }
75
+ // A forward or back separator names a nested destination, which the dialog
76
+ // is for — the escape itself is the `..` segment, and that stays refused.
77
+ expect((await create('tmp-create-probe', 'sub/inside.txt')).status).toBe(200);
78
+ expect((await create('tmp-create-probe', 'sub\\also-inside.txt')).status).toBe(200);
79
+ expect((await create('tmp-create-probe', '../escape.txt')).status).toBe(400);
80
+ expect(fs.existsSync(path.join(DIR, 'sub/inside.txt'))).toBe(true);
76
81
  expect(fs.existsSync(path.join(process.cwd(), 'escape.txt'))).toBe(false);
77
82
  });
78
83
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Nested paths at the route: a create and a rename that name SEGMENTS.
8
+ *
9
+ * The Files panel's dialogs used to accept one segment, so `lib/parse.ts` was
10
+ * unreachable from the UI — the user had to create `lib`, expand it, then the
11
+ * file. Both actions now take a relative path, and the rules that keep that
12
+ * inside the folder the user named are asserted here because they are the whole
13
+ * reason a nested path is safe:
14
+ *
15
+ * - a `..` SEGMENT is refused even when it would land back inside the root
16
+ * (`a/../b.ts` resolves there), so the refusal is about the path the user
17
+ * typed rather than about where it happened to land;
18
+ * - an ABSOLUTE path is refused, because `path.join(dir, abs)` discards the
19
+ * folder the dialog showed;
20
+ * - a rename resolves against the LISTED root, not the workspace root — a
21
+ * rename inside a nested repo used to resolve to a path outside the repo;
22
+ * - a rename never overwrites an existing destination, and never moves a folder
23
+ * into its own subtree.
24
+ */
25
+
26
+ import { afterEach, describe, expect, test } from 'bun:test';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { action } from '@/server/routes/fs/action';
30
+
31
+ /** Probes live in the repo root — the one directory the root allow-list always accepts. */
32
+ const DIR = path.join(process.cwd(), 'tmp-nested-probe');
33
+
34
+ function reset(files: Record<string, string> = {}): void {
35
+ fs.rmSync(DIR, { recursive: true, force: true });
36
+ fs.mkdirSync(DIR, { recursive: true });
37
+ for (const [rel, content] of Object.entries(files)) {
38
+ const target = path.join(DIR, rel);
39
+ fs.mkdirSync(path.dirname(target), { recursive: true });
40
+ fs.writeFileSync(target, content);
41
+ }
42
+ }
43
+
44
+ afterEach(() => {
45
+ fs.rmSync(DIR, { recursive: true, force: true });
46
+ });
47
+
48
+ async function post(fields: Record<string, string>) {
49
+ const form = new FormData();
50
+ form.append('root', process.cwd());
51
+ for (const [key, value] of Object.entries(fields)) form.append(key, value);
52
+ const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
53
+ return { status: res.status, body: (await res.json()) as { success?: boolean; kind?: string; path?: string; previousPath?: string; error?: string } };
54
+ }
55
+
56
+ describe('fs create with a nested path', () => {
57
+ test('creates every directory that leads to the file', async () => {
58
+ reset();
59
+
60
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/deep/parse.ts' });
61
+
62
+ expect(status).toBe(200);
63
+ expect(body).toEqual({ success: true, kind: 'file', path: 'tmp-nested-probe/lib/deep/parse.ts' });
64
+ expect(fs.readFileSync(path.join(DIR, 'lib/deep/parse.ts'), 'utf8')).toBe('');
65
+ });
66
+
67
+ test('creates a FOLDER when asked, and reports the kind back', async () => {
68
+ reset();
69
+
70
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'a/b', kind: 'folder' });
71
+
72
+ expect(status).toBe(200);
73
+ expect(body).toEqual({ success: true, kind: 'folder', path: 'tmp-nested-probe/a/b' });
74
+ expect(fs.statSync(path.join(DIR, 'a/b')).isDirectory()).toBe(true);
75
+ });
76
+
77
+ test('normalizes a backslash and repeated separators into one path', async () => {
78
+ reset();
79
+
80
+ const { body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'x\\\\y//z.ts' });
81
+
82
+ expect(body.path).toBe('tmp-nested-probe/x/y/z.ts');
83
+ expect(fs.existsSync(path.join(DIR, 'x/y/z.ts'))).toBe(true);
84
+ });
85
+
86
+ test('refuses a .. segment even when it would land inside the root', async () => {
87
+ reset({ 'keep/.gitkeep': '' });
88
+
89
+ // `keep/../escape.ts` resolves inside the probe directory, so a containment
90
+ // check alone would accept it — the refusal is about the path as typed.
91
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'keep/../escape.ts' });
92
+
93
+ expect(status).toBe(400);
94
+ expect(body.error).toContain('..');
95
+ expect(fs.existsSync(path.join(DIR, 'escape.ts'))).toBe(false);
96
+ });
97
+
98
+ test('refuses an absolute path', async () => {
99
+ reset();
100
+
101
+ for (const name of ['/tmp/absolute.ts', 'C:/absolute.ts', 'C:relative.ts']) {
102
+ expect((await post({ actionType: 'create', path: 'tmp-nested-probe', name })).status).toBe(400);
103
+ }
104
+ });
105
+
106
+ test('refuses a nested path whose final entry already exists', async () => {
107
+ reset({ 'lib/parse.ts': 'keep me' });
108
+
109
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/parse.ts' });
110
+
111
+ expect(status).toBe(409);
112
+ expect(body.error).toContain('already exists');
113
+ expect(fs.readFileSync(path.join(DIR, 'lib/parse.ts'), 'utf8')).toBe('keep me');
114
+ });
115
+ });
116
+
117
+ describe('fs rename with a nested path', () => {
118
+ test('renames into a new nested folder, creating its parents', async () => {
119
+ reset({ 'a.ts': 'A' });
120
+
121
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/lib/deep/a.ts' });
122
+
123
+ expect(status).toBe(200);
124
+ expect(body).toEqual({ success: true, path: 'tmp-nested-probe/lib/deep/a.ts', previousPath: 'tmp-nested-probe/a.ts' });
125
+ expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(false);
126
+ expect(fs.readFileSync(path.join(DIR, 'lib/deep/a.ts'), 'utf8')).toBe('A');
127
+ });
128
+
129
+ test('a plain rename inside one folder keeps its siblings', async () => {
130
+ reset({ 'src/a.ts': 'A', 'src/b.ts': 'B' });
131
+
132
+ const { body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src/a.ts', newPath: 'tmp-nested-probe/src/c.ts' });
133
+
134
+ expect(body.path).toBe('tmp-nested-probe/src/c.ts');
135
+ expect(fs.existsSync(path.join(DIR, 'src/b.ts'))).toBe(true);
136
+ });
137
+
138
+ test('refuses an existing destination instead of clobbering it', async () => {
139
+ reset({ 'a.ts': 'A', 'b.ts': 'B' });
140
+
141
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/b.ts' });
142
+
143
+ expect(status).toBe(409);
144
+ expect(body.error).toContain('already exists');
145
+ expect(fs.readFileSync(path.join(DIR, 'b.ts'), 'utf8')).toBe('B');
146
+ });
147
+
148
+ test('refuses moving a folder into its own subtree', async () => {
149
+ reset({ 'src/a.ts': 'A' });
150
+
151
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src', newPath: 'tmp-nested-probe/src/deep' });
152
+
153
+ expect(status).toBe(400);
154
+ expect(body.error).toContain('inside itself');
155
+ expect(fs.existsSync(path.join(DIR, 'src/a.ts'))).toBe(true);
156
+ });
157
+
158
+ test('refuses a .. segment and an absolute destination', async () => {
159
+ reset({ 'a.ts': 'A' });
160
+
161
+ expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/../out.ts' })).status).toBe(400);
162
+ expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: '/tmp/out.ts' })).status).toBe(400);
163
+ expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(true);
164
+ });
165
+
166
+ test('renaming to the same path is a no-op that still reports both ends', async () => {
167
+ reset({ 'a.ts': 'A' });
168
+
169
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/a.ts' });
170
+
171
+ expect(status).toBe(200);
172
+ expect(body.path).toBe('tmp-nested-probe/a.ts');
173
+ expect(fs.readFileSync(path.join(DIR, 'a.ts'), 'utf8')).toBe('A');
174
+ });
175
+
176
+ test('a missing source is refused rather than creating an empty entry', async () => {
177
+ reset();
178
+
179
+ const { status } = await post({ actionType: 'rename', path: 'tmp-nested-probe/gone.ts', newPath: 'tmp-nested-probe/here.ts' });
180
+
181
+ expect(status).toBe(404);
182
+ expect(fs.existsSync(path.join(DIR, 'here.ts'))).toBe(false);
183
+ });
184
+ });
@@ -3,10 +3,11 @@ import { errorResponse } from '@/server/lib/route-adapter';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { isMockMode } from '@/server/mock.server';
6
- import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
6
+ import { getDefaultFsRoot, isWithinRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
7
7
  import { runShell } from '@/server/lib/fs/shell';
8
8
  import { revealInFileManager } from '@/server/lib/fs/reveal';
9
- import { duplicateEntry, nextDuplicateName, pasteEntry } from '@/server/lib/fs/transfer';
9
+ import { duplicateEntry, nextDuplicateName, pasteEntry, toRelPath } from '@/server/lib/fs/transfer';
10
+ import { normalizeRelativePath } from '@/server/lib/fs/relative-path';
10
11
  import { transferRefusal } from '@/shared/lib/fs/clipboard';
11
12
  import { toDiskText } from '@/shared/lib/code/line-endings';
12
13
 
@@ -104,13 +105,15 @@ export async function action({ request }: ActionFunctionArgs) {
104
105
  await Bun.write(fullPath, content);
105
106
  return json({ success: true });
106
107
  } else if (actionType === 'create') {
107
- // The Files panel's "New file": an empty file beside the folder that was
108
- // right-clicked. Only a NAME is accepted, never a path — a separator
109
- // would let the panel write anywhere under the root, and the folder is
110
- // already named by `path`.
111
- const name = ((formData.get('name') as string) ?? '').trim();
112
- if (!name || name === '.' || name === '..' || /[/\\\0]/.test(name)) {
113
- return json({ error: 'Enter a file name without slashes' }, { status: 400 });
108
+ // The Files panel's "New file"/"New folder": an empty entry beside the
109
+ // folder that was right-clicked. The NAME may carry segments
110
+ // (`lib/parse.ts`), so one dialog creates a nested path and every
111
+ // intermediate directory with it — the grammar that keeps it inside the
112
+ // named folder lives in `normalizeRelativePath`, not in a slash test here.
113
+ const kind = formData.get('kind') === 'folder' ? 'folder' : 'file';
114
+ const name = normalizeRelativePath((formData.get('name') as string) ?? '');
115
+ if (!name) {
116
+ return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
114
117
  }
115
118
  const target = resolveWithinRoot(scopedRoot, path.join(filePath || '.', name));
116
119
  if (!target) {
@@ -122,21 +125,49 @@ export async function action({ request }: ActionFunctionArgs) {
122
125
  if ((await Bun.file(target).stat().catch(() => null)) !== null) {
123
126
  return json({ error: `${name} already exists` }, { status: 409 });
124
127
  }
125
- await fs.promises.mkdir(path.dirname(target), { recursive: true });
126
- await Bun.write(target, '');
128
+ if (kind === 'folder') await fs.promises.mkdir(target, { recursive: true });
129
+ else {
130
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
131
+ await Bun.write(target, '');
132
+ }
127
133
  // Relative to the LISTED root, which is the identity the tree renders by.
128
- return json({ success: true, path: path.relative(scopedRoot, target).split(path.sep).join('/') });
134
+ return json({ success: true, kind, path: toRelPath(scopedRoot, target) });
129
135
  } else if (actionType === 'delete') {
130
136
  await fs.promises.rm(fullPath, { recursive: true, force: true });
131
137
  return json({ success: true });
132
138
  } else if (actionType === 'rename') {
133
- const newPath = formData.get('newPath') as string;
134
- const fullNewPath = path.resolve(rootDir, newPath);
135
- if (fullNewPath !== rootDir && !fullNewPath.startsWith(rootDir + path.sep)) {
139
+ // `newPath` is relative to the LISTED root, exactly like `path` is. It
140
+ // used to be resolved against `rootDir`, so renaming inside a nested repo
141
+ // resolved to a path under the workspace root instead of the repo — a
142
+ // move out of the tree the row belongs to.
143
+ const newPath = normalizeRelativePath((formData.get('newPath') as string) ?? '');
144
+ if (!newPath) {
145
+ return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
146
+ }
147
+ const fullNewPath = resolveWithinRoot(scopedRoot, newPath);
148
+ if (!fullNewPath) {
136
149
  return json({ error: 'Invalid new path' }, { status: 403 });
137
150
  }
151
+ if (fullNewPath === fullPath) return json({ success: true, path: newPath, previousPath: filePath });
152
+ const source = await Bun.file(fullPath).stat().catch(() => null);
153
+ if (!source) return json({ error: 'That entry no longer exists' }, { status: 404 });
154
+ // A folder cannot be moved into itself: `rename(a, a/b)` fails with
155
+ // EINVAL on every platform, but with an errno the panel cannot explain —
156
+ // and the destination directory may not exist yet, so the check has to be
157
+ // on the PATHS rather than on the filesystem.
158
+ if (source.isDirectory() && isWithinRoot(fullPath, fullNewPath)) {
159
+ return json({ error: 'A folder cannot be moved inside itself' }, { status: 400 });
160
+ }
161
+ // `rename` clobbers silently, so an existing destination is refused: the
162
+ // entry the user meant to keep would otherwise disappear without a word.
163
+ if ((await Bun.file(fullNewPath).stat().catch(() => null)) !== null) {
164
+ return json({ error: `${newPath} already exists` }, { status: 409 });
165
+ }
166
+ // The destination's parents are created, which is what makes a rename
167
+ // INTO a new folder work — the same nested grammar the create accepts.
168
+ await fs.promises.mkdir(path.dirname(fullNewPath), { recursive: true });
138
169
  await fs.promises.rename(fullPath, fullNewPath);
139
- return json({ success: true });
170
+ return json({ success: true, path: newPath, previousPath: filePath });
140
171
  } else if (actionType === 'open_explorer') {
141
172
  const isDir = (await Bun.file(fullPath).stat().catch(() => null))?.isDirectory() ?? false;
142
173
  // A file reveals its containing directory — there is no portable "select
@@ -12,6 +12,7 @@ import { waitingBindings } from '@/server/routes/waiting/bindings';
12
12
  import { settingsBindings } from '@/server/routes/settings';
13
13
  import { fsBindings } from '@/server/routes/fs';
14
14
  import { foldersBindings } from '@/server/routes/folders';
15
+ import { worktreesBindings } from '@/server/routes/worktrees';
15
16
  import { filesBindings } from '@/server/routes/files';
16
17
  import { telemetryBindings } from '@/server/routes/telemetry';
17
18
  import { terminalBindings } from '@/server/routes/terminal';
@@ -42,6 +43,7 @@ const allBindings: HandlerBinding[] = [
42
43
  ...settingsBindings,
43
44
  ...fsBindings,
44
45
  ...foldersBindings,
46
+ ...worktreesBindings,
45
47
  ...filesBindings,
46
48
  ...telemetryBindings,
47
49
  ...terminalBindings,
@@ -0,0 +1,37 @@
1
+ import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
+ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell-release';
3
+ import { isDesktopShellKind, type DesktopShellKind } from '@/shared/lib/updates/shell-ua';
4
+
5
+ /**
6
+ * GET /api/updates/desktop?kind=macos|windows|linux — the newest desktop shell.
7
+ *
8
+ * ONE route with an explicit `kind`, unlike the mobile pair's two routes, and
9
+ * the difference is deliberate. `?kind` here is REQUIRED and refused when it is
10
+ * missing or names no desktop shell, so no absence can silently mean a platform:
11
+ * the mobile pair's split exists precisely because a defaulted parameter would
12
+ * have made "the newest shell" mean Android without anyone saying so.
13
+ *
14
+ * Only an INSTALLED desktop shell asks for this — the page reports its own
15
+ * version over the shell bridge (`__kabmoDesktop.shellVersion`), because a
16
+ * desktop app carries no User-Agent token. The `url` it answers with is the
17
+ * RELEASE PAGE rather than a file: every desktop artifact is per-architecture,
18
+ * and the server cannot know which architecture the window is running on.
19
+ *
20
+ * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
21
+ * is to omit a button it cannot back with a link.
22
+ */
23
+ export async function loader({ request }: { request: Request }) {
24
+ const kind = new URL(request.url).searchParams.get('kind');
25
+ if (!isDesktopShellKind(kind ?? '')) {
26
+ return json(
27
+ { error: 'kind must be one of macos, windows, linux' },
28
+ { status: 400, headers: NO_STORE_HEADERS },
29
+ );
30
+ }
31
+
32
+ const release: ShellRelease | null = await fetchShellRelease(kind as DesktopShellKind);
33
+ return json(
34
+ release ?? { url: null, latest: null, releaseUrl: null },
35
+ { headers: NO_STORE_HEADERS },
36
+ );
37
+ }
@@ -3,6 +3,7 @@ import * as updatesAndroid from '@/server/routes/updates/android';
3
3
  import * as updatesApply from '@/server/routes/updates/apply';
4
4
  import * as updatesChangelog from '@/server/routes/updates/changelog';
5
5
  import * as updatesCheck from '@/server/routes/updates/check';
6
+ import * as updatesDesktop from '@/server/routes/updates/desktop';
6
7
  import * as updatesIos from '@/server/routes/updates/ios';
7
8
 
8
9
  export const updatesBindings: HandlerBinding[] = [
@@ -10,5 +11,6 @@ export const updatesBindings: HandlerBinding[] = [
10
11
  ...bindingsFor(updatesApply, '/api/updates/apply'),
11
12
  ...bindingsFor(updatesChangelog, '/api/updates/changelog'),
12
13
  ...bindingsFor(updatesCheck, '/api/updates/check'),
14
+ ...bindingsFor(updatesDesktop, '/api/updates/desktop'),
13
15
  ...bindingsFor(updatesIos, '/api/updates/ios'),
14
16
  ];
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * POST /api/worktrees — create a worktree, and POST /api/worktrees/validate —
8
+ * the same plan without touching disk.
9
+ *
10
+ * Both go through `planWorktree`, so the dialog cannot be told "fine" by a check
11
+ * that the create then contradicts: every refusal a create can raise is raised
12
+ * by the validate call first, with the same code and the same field.
13
+ *
14
+ * The body carries the DIALOG's state, never a resolved path: a client that
15
+ * could name the directory could name one outside the project.
16
+ */
17
+
18
+ import { json } from '@/server/lib/remix-compat';
19
+ import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
20
+ import { methodNotAllowed } from '@/server/lib/route-adapter';
21
+ import { getDb } from '@/server/db.server';
22
+ import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
23
+ import { getDataDir } from '@/server/lib/lifecycle/paths';
24
+ import { putWorktreeRow, worktreeIdentityFor } from '@/server/lib/db/worktree-registry.server';
25
+ import { asWorktreeFailure } from '@/server/lib/fs/worktree/errors';
26
+ import { createWorktree, planWorktree } from '@/server/lib/fs/worktree/create';
27
+ import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
28
+ import { readWorktreeSettings } from '@/server/lib/fs/worktree/settings.server';
29
+ import type { WorktreeValidation } from '@/shared/lib/workspace/worktree/types';
30
+ import { isMock, mockRefusal, resolveWorktreeProject, worktreeFailureResponse } from '@/server/routes/worktrees/shared';
31
+
32
+ interface CreateBody {
33
+ mode?: unknown;
34
+ branch?: unknown;
35
+ worktreeName?: unknown;
36
+ startRef?: unknown;
37
+ carryIgnored?: unknown;
38
+ }
39
+
40
+ function readBody(body: CreateBody) {
41
+ return {
42
+ mode: body.mode === 'existing' ? ('existing' as const) : ('new' as const),
43
+ branch: typeof body.branch === 'string' ? body.branch : '',
44
+ worktreeName: typeof body.worktreeName === 'string' ? body.worktreeName : '',
45
+ startRef: typeof body.startRef === 'string' ? body.startRef : undefined,
46
+ carryIgnored: typeof body.carryIgnored === 'boolean' ? body.carryIgnored : undefined,
47
+ };
48
+ }
49
+
50
+ export async function action({ request }: ActionFunctionArgs) {
51
+ if (request.method !== 'POST') return methodNotAllowed({ request, params: {} });
52
+ if (isMock()) return mockRefusal();
53
+
54
+ const url = new URL(request.url);
55
+ const resolved = await resolveWorktreeProject(url.searchParams.get('root'), url.searchParams.get('folderId'));
56
+ if (resolved.error) return resolved.error;
57
+ const projectPath = resolved.root as string;
58
+
59
+ const body = (await request.json().catch(() => ({}))) as CreateBody;
60
+ const parsed = readBody(body);
61
+ const settings = await readWorktreeSettings();
62
+ const db = await getDb();
63
+ const deps = {
64
+ identityFor: (candidate: string) => worktreeIdentityFor(db, candidate),
65
+ carryIgnored: settings.carryIgnored,
66
+ dataDir: getDataDir(),
67
+ configuredDirectory: settings.directory,
68
+ record: async (row: {
69
+ path: string;
70
+ primaryRoot: string;
71
+ projectId: string;
72
+ branch: string;
73
+ startRef: string;
74
+ createdAt: string;
75
+ }) => {
76
+ await putWorktreeRow(db, {
77
+ path: row.path,
78
+ primary_root: row.primaryRoot,
79
+ project_id: row.projectId,
80
+ branch: row.branch,
81
+ start_ref: row.startRef,
82
+ created_at: row.createdAt,
83
+ });
84
+ },
85
+ };
86
+
87
+ try {
88
+ const created = await createWorktree({ projectPath, ...parsed }, deps);
89
+ await invalidateWorktreeTopology(created.primaryRoot);
90
+ // The new folder is structure: every sidebar re-reads, and the panels that
91
+ // already point at this repository drop their cached topology.
92
+ emitRealtimeSignal('sidebar-structure');
93
+ emitRealtimeSignal('workspace-dirty');
94
+ return json({ worktree: created, carried: created.carried });
95
+ } catch (error) {
96
+ return worktreeFailureResponse(error);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The same plan, without creating anything. Answers every failure rather than
102
+ * the first, because a dialog that shows one error at a time makes the user
103
+ * discover the second one after fixing the first.
104
+ */
105
+ export async function validateAction({ request }: ActionFunctionArgs) {
106
+ if (request.method !== 'POST') return methodNotAllowed({ request, params: {} });
107
+ if (isMock()) return mockRefusal();
108
+
109
+ const url = new URL(request.url);
110
+ const resolved = await resolveWorktreeProject(url.searchParams.get('root'), url.searchParams.get('folderId'));
111
+ if (resolved.error) return resolved.error;
112
+ const projectPath = resolved.root as string;
113
+
114
+ const body = (await request.json().catch(() => ({}))) as CreateBody;
115
+ const parsed = readBody(body);
116
+ const settings = await readWorktreeSettings();
117
+ const db = await getDb();
118
+
119
+ try {
120
+ const plan = await planWorktree(
121
+ { projectPath, ...parsed },
122
+ {
123
+ identityFor: (candidate: string) => worktreeIdentityFor(db, candidate),
124
+ dataDir: getDataDir(),
125
+ configuredDirectory: settings.directory,
126
+ },
127
+ );
128
+ const payload: WorktreeValidation = {
129
+ ok: true,
130
+ failures: [],
131
+ resolved: { name: plan.name, directory: plan.directory, branch: plan.branch },
132
+ };
133
+ return json(payload);
134
+ } catch (error) {
135
+ const payload: WorktreeValidation = { ok: false, failures: [asWorktreeFailure(error)] };
136
+ return json(payload, { status: 400 });
137
+ }
138
+ }
@@ -0,0 +1,25 @@
1
+ import { bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
2
+ import * as worktreesList from '@/server/routes/worktrees/list';
3
+ import { action as createAction, validateAction } from '@/server/routes/worktrees/create';
4
+ import { action as removeAction } from '@/server/routes/worktrees/remove';
5
+
6
+ /**
7
+ * `/api/worktrees` — list, create, validate, remove.
8
+ *
9
+ * Every binding is explicit rather than `actionBindings`, and that is the whole
10
+ * reason this file exists: `actionBindings` mounts all four mutating verbs AND a
11
+ * 405 GET fallback, and Elysia lets a later registration win — so mounting the
12
+ * create module that way would replace the list loader with a "method not
13
+ * allowed" response on the same path. (`sessions/index.ts` records the same trap
14
+ * for the batch-delete routes.)
15
+ *
16
+ * `validate` is a STATIC segment registered before the collection path.
17
+ * `DELETE` takes the worktree path in its BODY, not in the URL: a path contains
18
+ * separators, and a wildcard segment would also answer for `/validate`.
19
+ */
20
+ export const worktreesBindings: HandlerBinding[] = [
21
+ ...bindingsFor(worktreesList, '/api/worktrees'),
22
+ { method: 'POST', path: '/api/worktrees/validate', handler: validateAction },
23
+ { method: 'POST', path: '/api/worktrees', handler: createAction },
24
+ { method: 'DELETE', path: '/api/worktrees', handler: removeAction },
25
+ ];