@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
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { useRef, useState } from 'preact/hooks';
27
27
  import type { FsNode } from '@/shared/types';
28
- import { rehydrateTree, setChildrenAt } from '@/shared/lib/fs/file-tree';
28
+ import { placeEntry, rehydrateTree, setChildrenAt, takeEntry, rekeyCache } from '@/shared/lib/fs/file-tree';
29
29
  import { isRecord } from '@/shared/lib/util/guards';
30
30
 
31
31
  /**
@@ -86,6 +86,20 @@ export interface FileListing {
86
86
  loadChildren: (path: string) => Promise<void>;
87
87
  /** Adopt a realtime root snapshot, then re-read the open folders. */
88
88
  adoptRoot: (payload: unknown) => void;
89
+ /**
90
+ * Insert a row the panel just created, without a reload.
91
+ *
92
+ * A create answers with the entry's path, so the tree already knows the whole
93
+ * result; re-reading the root plus every open folder to learn it back would
94
+ * be one request per open folder for an answer the caller holds.
95
+ */
96
+ insertEntry: (entry: { path: string; kind: 'file' | 'folder' }) => void;
97
+ /**
98
+ * Move a row the panel just renamed, without a reload. Returns the folders
99
+ * whose path moved, so the caller can rekey its expansion set — a renamed
100
+ * folder's open branches are still open, under their new names.
101
+ */
102
+ moveEntry: (fromPath: string, toPath: string) => void;
89
103
  /** Forget the root listing and every cached child (a repo switch). */
90
104
  reset: () => void;
91
105
  }
@@ -117,6 +131,8 @@ export function useFileListing({
117
131
  const scopeRef = useRef(scope);
118
132
  scopeRef.current = scope;
119
133
  const listedScopeRef = useRef('');
134
+ /** The absolute base dir the current listing was read for — what `commit` stamps. */
135
+ const listedRootRef = useRef('');
120
136
  const expandedPathsRef = useRef(expandedPaths);
121
137
  expandedPathsRef.current = expandedPaths;
122
138
 
@@ -132,6 +148,7 @@ export function useFileListing({
132
148
  /** Render the tree from the current root listing plus the children cache. */
133
149
  const commit = (requested: string, root: string) => {
134
150
  listedScopeRef.current = requested;
151
+ listedRootRef.current = root;
135
152
  setState({
136
153
  scope: requested,
137
154
  files: rehydrateTree(rootFilesRef.current, childrenCacheRef.current),
@@ -223,9 +240,45 @@ export function useFileListing({
223
240
  childrenCacheRef.current = {};
224
241
  rootFilesRef.current = [];
225
242
  listedScopeRef.current = '';
243
+ listedRootRef.current = '';
226
244
  setState({ scope: '', files: [], root: '' });
227
245
  };
228
246
 
247
+ /**
248
+ * The tree edits below patch the same two sources a read fills — the root
249
+ * list and the children cache — and then commit, so `rehydrateTree` remains
250
+ * the only thing that builds what is rendered. Editing the rendered tree
251
+ * directly would be undone by the next commit, which rebuilds from these.
252
+ */
253
+ const insertEntry = (entry: { path: string; kind: 'file' | 'folder' }): void => {
254
+ const requested = scopeRef.current;
255
+ if (listedScopeRef.current !== requested) return;
256
+ const node: FsNode = {
257
+ name: entry.path.slice(entry.path.lastIndexOf('/') + 1),
258
+ path: entry.path,
259
+ type: entry.kind,
260
+ ignored: false,
261
+ };
262
+ const placed = placeEntry(rootFilesRef.current, childrenCacheRef.current, node);
263
+ if (placed.nodes === rootFilesRef.current) return;
264
+ rootFilesRef.current = placed.nodes;
265
+ childrenCacheRef.current = placed.cache;
266
+ commit(requested, listedRootRef.current);
267
+ };
268
+
269
+ const moveEntry = (fromPath: string, toPath: string): void => {
270
+ const requested = scopeRef.current;
271
+ if (listedScopeRef.current !== requested) return;
272
+ const taken = takeEntry(rootFilesRef.current, childrenCacheRef.current, fromPath);
273
+ if (!taken.entry) return;
274
+ childrenCacheRef.current = rekeyCache(taken.cache, fromPath, toPath);
275
+ const moved: FsNode = { ...taken.entry, path: toPath, name: toPath.slice(toPath.lastIndexOf('/') + 1) };
276
+ const placed = placeEntry(taken.nodes, childrenCacheRef.current, moved);
277
+ rootFilesRef.current = placed.nodes;
278
+ childrenCacheRef.current = placed.cache;
279
+ commit(requested, listedRootRef.current);
280
+ };
281
+
229
282
  // A listing read for another scope is not this one's to render: the panel
230
283
  // shows an empty tree until the current scope's read lands.
231
284
  const current = state.scope === scope;
@@ -237,6 +290,8 @@ export function useFileListing({
237
290
  reload,
238
291
  loadChildren,
239
292
  adoptRoot,
293
+ insertEntry,
294
+ moveEntry,
240
295
  reset,
241
296
  };
242
297
  }
@@ -22,7 +22,7 @@ import type { FormEvent, RefObject } from 'preact/compat';
22
22
  import { useFetcher } from '@/client/lib/router/fetcher';
23
23
  import { toAbsolutePath } from '@/shared/lib/fs/paths';
24
24
  import { copyToClipboard } from '@/client/hooks/ui/clipboard';
25
- import { useFileCreate } from '@/client/hooks/workspace/file-create';
25
+ import { useFileCreate, type CreatedEntry } from '@/client/hooks/workspace/file-create';
26
26
  import type { FileClipboard } from '@/client/hooks/workspace/file-clipboard';
27
27
  import type { FsActionResponse } from '@/shared/types';
28
28
 
@@ -40,7 +40,17 @@ export interface FileActionDeps {
40
40
  /** Which diff to open, when the row is a changed file. */
41
41
  diff?: { staged?: boolean; status?: string };
42
42
  onOpenFile?: (file: { path: string; name: string; root?: string; repo?: string }) => void;
43
+ /** Re-read the tree — a DELETE has no local answer, so it re-reads. */
43
44
  onActionComplete: () => void;
45
+ /**
46
+ * A row the panel just created, placed locally.
47
+ *
48
+ * A create answers with the whole result, so the tree inserts it rather than
49
+ * re-reading the root plus every open folder to learn it back.
50
+ */
51
+ onCreated?: (entry: CreatedEntry) => void;
52
+ /** A row the panel just renamed, moved locally — same reason as `onCreated`. */
53
+ onRenamed?: (fromPath: string, toPath: string) => void;
44
54
  /** The panel's clipboard: Cut/Copy fill it, Paste reads it. */
45
55
  clipboard: FileClipboard;
46
56
  /** Whether this row is a folder — what Cut/Copy/Paste need to name their target. */
@@ -56,12 +66,16 @@ export interface FileActions {
56
66
  showDeleteModal: boolean;
57
67
  showHistoryModal: boolean;
58
68
  showCreateModal: boolean;
69
+ /** Whether the open create dialog makes a folder. */
70
+ createKind: 'file' | 'folder';
59
71
  /** The server's refusal for the create request, rendered inside its dialog. */
60
72
  createError: string | null;
61
73
  createName: string;
62
74
  setCreateName: (value: string) => void;
63
75
  renameValue: string;
64
76
  setRenameValue: (value: string) => void;
77
+ /** The server's refusal for the rename request, rendered inside its dialog. */
78
+ renameError: string | null;
65
79
  /** True while a request is in flight; every dialog's buttons read it. */
66
80
  busy: boolean;
67
81
  /** Label of the row's Paste action, or null when a paste cannot land here. */
@@ -79,7 +93,7 @@ export interface FileActions {
79
93
  closeHistory: () => void;
80
94
  }
81
95
 
82
- export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipboard, isFolder }: FileActionDeps): FileActions {
96
+ export function useFileActions({ file, diff, onOpenFile, onActionComplete, onCreated, onRenamed, clipboard, isFolder }: FileActionDeps): FileActions {
83
97
  const actionFetcher = useFetcher<FsActionResponse>();
84
98
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
85
99
  const contextMenuRef = useRef<HTMLDivElement>(null);
@@ -87,6 +101,7 @@ export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipb
87
101
  const [showDeleteModal, setShowDeleteModal] = useState(false);
88
102
  const [showHistoryModal, setShowHistoryModal] = useState(false);
89
103
  const [renameValue, setRenameValue] = useState(file.path);
104
+ const [renameError, setRenameError] = useState<string | null>(null);
90
105
 
91
106
  const repo = file.repo;
92
107
  const rootPath = file.rootPath;
@@ -98,7 +113,10 @@ export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipb
98
113
  folderPath: file.path,
99
114
  rootPath,
100
115
  repo,
101
- onCreated: onActionComplete,
116
+ // The tree places the entry itself and does NOT reload: the response names
117
+ // the whole result, and re-reading the root plus every open folder to learn
118
+ // it back is the reload this path exists to avoid.
119
+ onCreated: (entry) => (onCreated ? onCreated(entry) : onActionComplete()),
102
120
  onOpenCreated: (created) => onOpenFile?.({ ...created, root: rootPath, repo: repo || '.' }),
103
121
  });
104
122
 
@@ -124,7 +142,15 @@ export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipb
124
142
  if (data.success && !data.type) {
125
143
  setShowRenameModal(false);
126
144
  setShowDeleteModal(false);
127
- onActionComplete();
145
+ setRenameError(null);
146
+ // A rename response names both ends, so the tree moves the row locally
147
+ // instead of re-reading every open folder to discover the move.
148
+ if (data.previousPath && data.path && onRenamed) onRenamed(data.previousPath, data.path);
149
+ else onActionComplete();
150
+ } else if (!data.success && data.error && showRenameModal) {
151
+ // A refusal keeps the dialog open with the server's own words beside the
152
+ // field: "already exists" is a message about what to type next.
153
+ setRenameError(data.error);
128
154
  }
129
155
  // eslint-disable-next-line react-hooks/exhaustive-deps
130
156
  }, [actionFetcher.state, actionFetcher.data]);
@@ -155,6 +181,7 @@ export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipb
155
181
 
156
182
  const handleAction = (actionType: string) => {
157
183
  setContextMenu(null);
184
+ setRenameError(null);
158
185
  if (actionType === 'view') {
159
186
  openFile();
160
187
  } else if (actionType === 'diff') {
@@ -186,7 +213,9 @@ export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipb
186
213
  setRenameValue(file.path);
187
214
  setShowRenameModal(true);
188
215
  } else if (actionType === 'new_file') {
189
- create.openDialog();
216
+ create.openDialog('file');
217
+ } else if (actionType === 'new_folder') {
218
+ create.openDialog('folder');
190
219
  } else if (actionType === 'delete') {
191
220
  setShowDeleteModal(true);
192
221
  } else if (actionType === 'cut') {
@@ -202,6 +231,7 @@ export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipb
202
231
 
203
232
  const submitRename = (e: FormEvent) => {
204
233
  e.preventDefault();
234
+ setRenameError(null);
205
235
  const fd = new FormData();
206
236
  fd.append('actionType', 'rename');
207
237
  fd.append('path', file.path);
@@ -225,11 +255,13 @@ export function useFileActions({ file, diff, onOpenFile, onActionComplete, clipb
225
255
  showDeleteModal,
226
256
  showHistoryModal,
227
257
  showCreateModal: create.open,
258
+ createKind: create.kind,
228
259
  createError: create.error,
229
260
  createName: create.name,
230
261
  setCreateName: create.setName,
231
262
  renameValue,
232
263
  setRenameValue,
264
+ renameError,
233
265
  busy: actionFetcher.state !== 'idle' || create.busy || clipboard.busy,
234
266
  pasteLabel,
235
267
  fetcherState: actionFetcher.state,
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Client state for the worktree surfaces: the list, creation and removal.
8
+ *
9
+ * One module for all three because they share the same two facts — the project
10
+ * the request is scoped to and the failure shape the server answers with — and
11
+ * splitting them would put the fetch envelope in three places.
12
+ *
13
+ * Nothing here invents a path: the server resolves the destination from the
14
+ * dialog's NAME and reports back what it used, so the client cannot name a
15
+ * directory outside the project.
16
+ */
17
+
18
+ import { useCallback, useEffect, useState } from 'preact/hooks';
19
+ import type { WorktreeFailure, WorktreeInfo, WorktreeListPayload, WorktreeValidation } from '@/shared/lib/workspace/worktree/types';
20
+
21
+ export interface WorktreeRequestScope {
22
+ /** The project's `project_path`, sent as `?root=`. */
23
+ root: string | null;
24
+ /** Sent as `?folderId=` so an unbound-but-valid folder still resolves. */
25
+ folderId?: number | string | null;
26
+ }
27
+
28
+ function queryFor(scope: WorktreeRequestScope): string {
29
+ const params = new URLSearchParams();
30
+ if (scope.root) params.set('root', scope.root);
31
+ if (scope.folderId !== null && scope.folderId !== undefined) params.set('folderId', String(scope.folderId));
32
+ const query = params.toString();
33
+ return query ? `?${query}` : '';
34
+ }
35
+
36
+ /** A refusal the UI reports verbatim; `field` marks the input that caused it. */
37
+ export class WorktreeRequestError extends Error {
38
+ readonly failure: WorktreeFailure;
39
+
40
+ constructor(failure: WorktreeFailure) {
41
+ super(failure.message);
42
+ this.name = 'WorktreeRequestError';
43
+ this.failure = failure;
44
+ }
45
+ }
46
+
47
+ async function readFailure(response: Response): Promise<never> {
48
+ const body = (await response.json().catch(() => null)) as (WorktreeFailure & { error?: string }) | null;
49
+ throw new WorktreeRequestError({
50
+ code: body?.code ?? 'create_failed',
51
+ message: body?.message ?? body?.error ?? `HTTP ${response.status}`,
52
+ ...(body?.field ? { field: body.field } : {}),
53
+ ...(body?.count !== undefined ? { count: body.count } : {}),
54
+ });
55
+ }
56
+
57
+ /** The project's worktrees, with the facts each row shows. */
58
+ export function useWorktrees(scope: WorktreeRequestScope, enabled: boolean) {
59
+ const [payload, setPayload] = useState<WorktreeListPayload | null>(null);
60
+ const [loading, setLoading] = useState(false);
61
+ const [error, setError] = useState<string | null>(null);
62
+
63
+ const refresh = useCallback(async () => {
64
+ if (!enabled) return;
65
+ setLoading(true);
66
+ try {
67
+ const response = await fetch(`/api/worktrees${queryFor(scope)}`);
68
+ if (!response.ok) await readFailure(response);
69
+ setPayload((await response.json()) as WorktreeListPayload);
70
+ setError(null);
71
+ } catch (cause) {
72
+ // Kept apart from the payload: an empty list and a failed read are
73
+ // different answers, and only one of them means nothing is there.
74
+ setError(cause instanceof Error ? cause.message : String(cause));
75
+ } finally {
76
+ setLoading(false);
77
+ }
78
+ }, [enabled, scope.root, scope.folderId]);
79
+
80
+ useEffect(() => {
81
+ void refresh();
82
+ }, [refresh]);
83
+
84
+ return {
85
+ payload,
86
+ worktrees: payload?.worktrees ?? ([] as WorktreeInfo[]),
87
+ /** The resolved parent directory a create would use. */
88
+ base: payload?.base ?? '',
89
+ loading,
90
+ error,
91
+ refresh,
92
+ };
93
+ }
94
+
95
+ export interface CreateWorktreeRequest {
96
+ mode: 'new' | 'existing';
97
+ branch?: string;
98
+ worktreeName?: string;
99
+ startRef?: string;
100
+ }
101
+
102
+ /** The plan a create would use, without creating anything. */
103
+ export async function validateWorktree(
104
+ scope: WorktreeRequestScope,
105
+ request: CreateWorktreeRequest,
106
+ ): Promise<WorktreeValidation> {
107
+ const response = await fetch(`/api/worktrees/validate${queryFor(scope)}`, {
108
+ method: 'POST',
109
+ headers: { 'content-type': 'application/json' },
110
+ body: JSON.stringify(request),
111
+ });
112
+ const body = (await response.json().catch(() => null)) as WorktreeValidation | null;
113
+ if (!body) return { ok: false, failures: [{ code: 'create_failed', message: `HTTP ${response.status}` }] };
114
+ return body;
115
+ }
116
+
117
+ export async function createWorktree(
118
+ scope: WorktreeRequestScope,
119
+ request: CreateWorktreeRequest,
120
+ ): Promise<WorktreeInfo> {
121
+ const response = await fetch(`/api/worktrees${queryFor(scope)}`, {
122
+ method: 'POST',
123
+ headers: { 'content-type': 'application/json' },
124
+ body: JSON.stringify(request),
125
+ });
126
+ if (!response.ok) await readFailure(response);
127
+ const body = (await response.json()) as { worktree: WorktreeInfo };
128
+ return body.worktree;
129
+ }
130
+
131
+ export interface RemoveWorktreeResult {
132
+ removed: true;
133
+ archived: string[];
134
+ branchDeleted: boolean;
135
+ branchKept: boolean;
136
+ }
137
+
138
+ export async function removeWorktree(
139
+ scope: WorktreeRequestScope,
140
+ worktreePath: string,
141
+ options: { deleteBranch?: boolean } = {},
142
+ ): Promise<RemoveWorktreeResult> {
143
+ const response = await fetch(`/api/worktrees${queryFor(scope)}`, {
144
+ method: 'DELETE',
145
+ headers: { 'content-type': 'application/json' },
146
+ body: JSON.stringify({ path: worktreePath, deleteBranch: options.deleteBranch === true }),
147
+ });
148
+ if (!response.ok) await readFailure(response);
149
+ return (await response.json()) as RemoveWorktreeResult;
150
+ }
@@ -245,6 +245,25 @@
245
245
  }
246
246
  }
247
247
 
248
+ /* The Files panel's tree, on a finger.
249
+ *
250
+ * Every row is `select-none`, but a long press on iOS/Android still raises the
251
+ * native callout and a selection of the CONTAINER's text — measured on a phone:
252
+ * holding a file row highlighted the whole panel instead of opening its menu.
253
+ * `user-select` alone does not stop it; `-webkit-touch-callout` is what removes
254
+ * the callout menu, and `user-select: none` on the scroller (not only the rows)
255
+ * is what keeps a press that drifts between two rows from selecting the text of
256
+ * both. Rows themselves stay `select-none` and no row carries `select-text`:
257
+ * a path is copied through the context menu's Copy Path, never by dragging over
258
+ * it, and a selectable row makes every press a candidate for a selection.
259
+ */
260
+ .file-tree-touch,
261
+ .file-tree-touch * {
262
+ -webkit-touch-callout: none;
263
+ -webkit-user-select: none;
264
+ user-select: none;
265
+ }
266
+
248
267
  /* Horizontally scrollable strips (mobile tab bars) that pan without a visible
249
268
  * scrollbar track eating vertical space. */
250
269
  .no-scrollbar {
@@ -268,7 +268,9 @@ startWaitingRuntime();
268
268
  *
269
269
  * A Kabmo update replaces the store and leaves the install — it is a
270
270
  * copied directory of ordinary files — so this is the only thing that notices.
271
- * It is version-gated and skips git clones, so a plugin the user installed from
271
+ * An install Kabmo wrote is compared by CONTENT, so a shipped fix applies even
272
+ * when its version did not move; a directory of unknown provenance keeps the
273
+ * version gate, and git clones are skipped, so a plugin the user installed from
272
274
  * a URL is never overwritten by a store entry that happens to share its id.
273
275
  *
274
276
  * BEFORE the server halves load, and that order is the whole point: a plugin's
@@ -283,7 +285,10 @@ startWaitingRuntime();
283
285
  try {
284
286
  const synced = await syncBundledPlugins();
285
287
  for (const item of synced.updated) {
286
- console.log(`[kabmo] plugins: synced ${item.pluginId} ${item.from} → ${item.to}`);
288
+ // A content-only sync has no version to show, and printing `1.0.0 → 1.0.0`
289
+ // would read as a no-op when a shipped fix actually landed.
290
+ const span = item.contentOnly ? `${item.to} (content changed, version unchanged)` : `${item.from} → ${item.to}`;
291
+ console.log(`[kabmo] plugins: synced ${item.pluginId} ${span}`);
287
292
  if (item.warning) console.error(`[plugins] ${item.pluginId}: rebuild after sync failed — ${item.warning}`);
288
293
  }
289
294
  for (const failure of synced.failed) console.error(`[plugins] ${failure.pluginId}: bundled sync failed — ${failure.error}`);
@@ -50,11 +50,21 @@ describe('window-drag contract', () => {
50
50
  /**
51
51
  * The shell's hit test starts at the element under the pointer and walks up.
52
52
  * A header without the attribute is a header that cannot move the window.
53
+ *
54
+ * EVERY header that can be the window's top strip is listed, not just the
55
+ * desktop pair. A desktop shell window may be narrower than `App`'s 768px
56
+ * layout breakpoint (all three shells allow 720px), so the phone layout's
57
+ * headers are title bars too — and the mobile ones were missing the attribute
58
+ * entirely, which on macOS left a narrowed window with no draggable strip in
59
+ * its main screen and no way to move it.
53
60
  */
54
- test('both title-bar headers carry data-drag-region', () => {
61
+ test('every title-bar header carries data-drag-region', () => {
55
62
  for (const path of [
56
63
  'src/client/components/layout/desktop-layout/TopNavbar.tsx',
57
64
  'src/client/components/layout/session-sidebar/Header.tsx',
65
+ 'src/client/components/mobile/mobile-main-view/Header.tsx',
66
+ 'src/client/components/mobile/mobile-session-sidebar/Header.tsx',
67
+ 'src/client/components/mobile/RightSidebar.tsx',
58
68
  ]) {
59
69
  const file = source(path);
60
70
  expect(file).toContain('dragRegionProps()');
@@ -63,6 +73,37 @@ describe('window-drag contract', () => {
63
73
  expect(source('src/client/lib/desktop/titlebar.ts')).toContain("'data-drag-region'");
64
74
  });
65
75
 
76
+ /**
77
+ * The phone headers reserve the shell's own inset, not only the PWA's.
78
+ *
79
+ * `titlebarPaddingLeft` reads `env(titlebar-area-x)`, which a native WKWebView
80
+ * never defines — so the shell's 76px of traffic lights has to come from
81
+ * `titlebarInsetLeft`, the helper the desktop headers already use. Without it
82
+ * the phone header's hamburger and wordmark sat UNDER the traffic lights.
83
+ */
84
+ test('the phone headers use the shell inset', () => {
85
+ for (const path of [
86
+ 'src/client/components/mobile/mobile-main-view/Header.tsx',
87
+ 'src/client/components/mobile/mobile-session-sidebar/Header.tsx',
88
+ 'src/client/components/mobile/RightSidebar.tsx',
89
+ ]) {
90
+ expect(source(path)).toContain('titlebarInsetLeft(');
91
+ }
92
+ });
93
+
94
+ /**
95
+ * A header marked as a drag region makes its controls drag handles unless
96
+ * they say otherwise — `app-region` INHERITS (WebView2's spec, enforced by
97
+ * Chromium 152), and a drag handle swallows the click.
98
+ */
99
+ test('a drag-region header marks its controls no-drag', () => {
100
+ const pairs: Array<[string, string]> = [
101
+ ['src/client/components/mobile/RightSidebar.tsx', 'titlebar-no-drag'],
102
+ ['src/client/components/common/header-controls/index.tsx', 'titlebar-no-drag'],
103
+ ];
104
+ for (const [path, marker] of pairs) expect(source(path)).toContain(marker);
105
+ });
106
+
66
107
  /** The PWA keeps its drag region: the property has to reach the browser. */
67
108
  test('the webkit property survives the bundle', async () => {
68
109
  const out = await build(`
@@ -18,6 +18,7 @@ import { ensureScheduleSchema } from '@/server/lib/schedule/schema.server';
18
18
  import { ensureWaitingSchema } from '@/server/lib/waiting/schema.server';
19
19
  import { ensurePluginSchema } from '@/server/lib/plugins/schema.server';
20
20
  import { ensureModelRolePresetSchema } from '@/server/lib/model-roles/schema.server';
21
+ import { ensureWorktreeSchema } from '@/server/lib/db/worktree-registry.server';
21
22
  import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
22
23
  import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
23
24
 
@@ -112,6 +113,9 @@ export async function initSchema(db: DbClient): Promise<void> {
112
113
  await ensurePluginSchema(db);
113
114
  // Named model-role presets (the role editor's preset picker).
114
115
  await ensureModelRolePresetSchema(db);
116
+ // Worktree registry: the ref each worktree branched from, and the identity
117
+ // cache that keys its folder.
118
+ await ensureWorktreeSchema(db);
115
119
 
116
120
  await db.exec(`
117
121
  CREATE TABLE IF NOT EXISTS files (
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `worktrees` table: one row per worktree Kabmo created.
8
+ *
9
+ * Everything a worktree row shows is read from git — except two things this
10
+ * table is the only home for:
11
+ *
12
+ * - **`start_ref`**, the ref the branch was created from. `aheadOfStart` gates
13
+ * the delete dialog's branch checkbox, and git cannot answer it later: the
14
+ * branch has moved on, and for an existing-branch worktree there was no start
15
+ * ref at all.
16
+ * - **`project_id`**, the repository's identity, which is what the default
17
+ * root is keyed by. It is a CACHE, not a derivation result: the derivation
18
+ * moves when a repository gains an unrelated root commit (see
19
+ * `worktree/project-id.ts`), so a stored value always wins.
20
+ */
21
+
22
+ import type { DbClient } from '@/server/lib/db/client';
23
+
24
+ const WORKTREE_SCHEMA_SQL = `
25
+ CREATE TABLE IF NOT EXISTS worktrees (
26
+ path TEXT PRIMARY KEY,
27
+ primary_root TEXT NOT NULL,
28
+ project_id TEXT NOT NULL,
29
+ branch TEXT NOT NULL,
30
+ start_ref TEXT NOT NULL DEFAULT '',
31
+ created_at TEXT NOT NULL
32
+ );
33
+ CREATE INDEX IF NOT EXISTS idx_worktrees_primary ON worktrees (primary_root);
34
+ `;
35
+
36
+ export interface WorktreeRow {
37
+ path: string;
38
+ primary_root: string;
39
+ project_id: string;
40
+ branch: string;
41
+ start_ref: string;
42
+ created_at: string;
43
+ }
44
+
45
+ /** Idempotent: the worktree registry, created on every database open. */
46
+ export async function ensureWorktreeSchema(db: DbClient): Promise<void> {
47
+ await db.exec(WORKTREE_SCHEMA_SQL);
48
+ }
49
+
50
+ /**
51
+ * The identity cache: the project id already recorded for this repository.
52
+ * `LIMIT 1` because every row of one repository carries the same value.
53
+ */
54
+ export async function worktreeIdentityFor(db: DbClient, primaryRoot: string): Promise<string | null> {
55
+ try {
56
+ const row = await db.get<{ project_id: string }>(
57
+ 'SELECT project_id FROM worktrees WHERE primary_root = ? LIMIT 1',
58
+ [primaryRoot],
59
+ );
60
+ return typeof row?.project_id === 'string' && row.project_id ? row.project_id : null;
61
+ } catch {
62
+ // A database that cannot answer is not a reason to refuse the operation:
63
+ // the derivation still gives a usable key.
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export async function worktreeRowFor(db: DbClient, worktreePath: string): Promise<WorktreeRow | undefined> {
69
+ return db.get<WorktreeRow>('SELECT * FROM worktrees WHERE path = ?', [worktreePath]);
70
+ }
71
+
72
+ export async function listWorktreeRows(db: DbClient, primaryRoot: string): Promise<WorktreeRow[]> {
73
+ return db.all<WorktreeRow>('SELECT * FROM worktrees WHERE primary_root = ?', [primaryRoot]);
74
+ }
75
+
76
+ export async function putWorktreeRow(db: DbClient, row: WorktreeRow): Promise<void> {
77
+ await db.run(
78
+ `INSERT INTO worktrees (path, primary_root, project_id, branch, start_ref, created_at)
79
+ VALUES (?, ?, ?, ?, ?, ?)
80
+ ON CONFLICT(path) DO UPDATE SET
81
+ primary_root = excluded.primary_root,
82
+ project_id = excluded.project_id,
83
+ branch = excluded.branch,
84
+ start_ref = excluded.start_ref`,
85
+ [row.path, row.primary_root, row.project_id, row.branch, row.start_ref, row.created_at],
86
+ );
87
+ }
88
+
89
+ export async function removeWorktreeRow(db: DbClient, worktreePath: string): Promise<void> {
90
+ await db.run('DELETE FROM worktrees WHERE path = ?', [worktreePath]);
91
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Canonical paths and containment for filesystem work.
8
+ *
9
+ * Two traps this exists to close, both measured:
10
+ *
11
+ * - **A spelling difference is not a difference.** On macOS git prints
12
+ * `/private/tmp/...` for a directory the caller typed as `/tmp/...`, and
13
+ * `path.resolve` alone does not reconcile them. A worktree compared against
14
+ * its own entry answered "not inside", so the session badge silently never
15
+ * appeared.
16
+ * - **A missing path cannot be `realpath`'d at all** — and that is exactly the
17
+ * `missing` worktree, whose directory was removed outside Kabmo. Resolving
18
+ * the nearest existing ANCESTOR and re-appending the tail is what lets such a
19
+ * worktree still be matched against the entry git keeps listing.
20
+ *
21
+ * Everything that compares two paths goes through here; `isWithinRoot` in
22
+ * `lib/fs/root.ts` stays the raw prefix test it always was.
23
+ */
24
+
25
+ import path from 'path';
26
+ import { realpath } from 'fs/promises';
27
+
28
+ /** Absolute, symlink-resolved, no trailing separator. */
29
+ export async function canonicalPath(target: string): Promise<string> {
30
+ const absolute = path.resolve(target).replace(/\/+$/, '');
31
+ const pending: string[] = [];
32
+ let cursor = absolute;
33
+ while (true) {
34
+ try {
35
+ const resolved = (await realpath(cursor)).replace(/\/+$/, '');
36
+ return pending.length > 0 ? [resolved, ...pending].join(path.sep) : resolved;
37
+ } catch {
38
+ const parent = path.dirname(cursor);
39
+ // The whole ancestor chain is gone (or this is the filesystem root): the
40
+ // `..`-folded path is the best answer that exists.
41
+ if (parent === cursor) return absolute;
42
+ pending.unshift(path.basename(cursor));
43
+ cursor = parent;
44
+ }
45
+ }
46
+ }
47
+
48
+ /** True when `target` is `root` itself or lies inside it. Both are canonicalized. */
49
+ export async function pathHolds(root: string, target: string): Promise<boolean> {
50
+ const [canonicalRoot, canonicalTarget] = await Promise.all([canonicalPath(root), canonicalPath(target)]);
51
+ return canonicalTarget === canonicalRoot || canonicalTarget.startsWith(`${canonicalRoot}${path.sep}`);
52
+ }