@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,6 +6,24 @@ import { highlightCode, highlightJson, tryParseJson } from '@/shared/lib/code/sy
6
6
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
7
7
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
8
8
 
9
+ /**
10
+ * The line box both sides of a code card share — gutter row and code line.
11
+ *
12
+ * A gutter row and the code line beside it are separate elements, so they agree
13
+ * only when their line boxes do. They did not: the gutter was `text-[10px]`
14
+ * while the code was `text-[11px]`, and both inherited `leading-relaxed` — a
15
+ * RATIO, so 10 × 1.625 = 16.25px against 11 × 1.625 = 17.875px. Each number
16
+ * then sat 1.6px higher than its line, and by the tenth line the number was a
17
+ * whole line above the code it labels (measured in the browser after a reload:
18
+ * `gutterRowH 16.25`, `preLineHeight 17.875`, `firstRowOffset 10`).
19
+ *
20
+ * The box is stated ONCE and applied to both containers, because a `<div>`
21
+ * holding one line takes its height from `line-height` — no per-row `h-[…]`
22
+ * that could drift from the constant. This is the same shape `Read.tsx` and
23
+ * `ExcerptCode` use.
24
+ */
25
+ const LINE_BOX = 'leading-[18px]';
26
+
9
27
  /** Panel khusus untuk tool `eval` — eksekusi kode script, browser eval, dsb. */
10
28
  export function Eval({ tool }: { tool: ToolCallData }) {
11
29
  const input = tool.input;
@@ -78,14 +96,14 @@ export function Eval({ tool }: { tool: ToolCallData }) {
78
96
  … {truncatedCode.skipped} earlier lines hidden
79
97
  </div>
80
98
  )}
81
- <div className="flex max-h-56 items-start overflow-x-auto font-mono text-[11px] leading-relaxed select-text">
82
- <div className="sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2.5 pl-2.5 pr-2 text-right text-[10px] text-ink/25">
99
+ <div className={`flex max-h-56 items-start overflow-x-auto font-mono text-[11px] ${LINE_BOX} select-text`}>
100
+ <div className={`sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2.5 pl-2.5 pr-2 text-right text-[10px] ${LINE_BOX} tabular-nums text-ink/25`}>
83
101
  {truncatedCode.text.split('\n').map((_, idx) => (
84
102
  <div key={idx}>{idx + 1}</div>
85
103
  ))}
86
104
  </div>
87
105
  <pre
88
- className="flex-1 overflow-x-auto p-2.5 whitespace-pre text-ink/85 font-mono"
106
+ className={`flex-1 overflow-x-auto p-2.5 whitespace-pre ${LINE_BOX} text-ink/85 font-mono`}
89
107
  dangerouslySetInnerHTML={{ __html: highlightedCode }}
90
108
  />
91
109
  </div>
@@ -157,14 +175,14 @@ export function Eval({ tool }: { tool: ToolCallData }) {
157
175
  </div>
158
176
 
159
177
  {jsonResult.isValid && jsonResult.pretty ? (
160
- <div className="flex max-h-56 items-start overflow-x-auto font-mono text-[11px] leading-relaxed select-text">
161
- <div className="sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2 pl-2.5 pr-2 text-right text-[10px] text-ink/25">
178
+ <div className={`flex max-h-56 items-start overflow-x-auto font-mono text-[11px] ${LINE_BOX} select-text`}>
179
+ <div className={`sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2.5 pl-2.5 pr-2 text-right text-[10px] ${LINE_BOX} tabular-nums text-ink/25`}>
162
180
  {jsonResult.pretty.split('\n').map((_, idx) => (
163
181
  <div key={idx}>{idx + 1}</div>
164
182
  ))}
165
183
  </div>
166
184
  <pre
167
- className="flex-1 overflow-x-auto p-2.5 whitespace-pre text-ink/85 font-mono"
185
+ className={`flex-1 overflow-x-auto p-2.5 whitespace-pre ${LINE_BOX} text-ink/85 font-mono`}
168
186
  dangerouslySetInnerHTML={{ __html: highlightedJson }}
169
187
  />
170
188
  </div>
@@ -29,7 +29,7 @@
29
29
 
30
30
  import { createPortal } from 'preact/compat';
31
31
  import type { ReactNode, RefObject } from 'preact/compat';
32
- import { ClipboardPaste, Copy, CopyPlus, Edit2, ExternalLink, Eye, FilePlus, GitCompare, History, Scissors, Trash2 } from 'lucide-preact';
32
+ import { ClipboardPaste, Copy, CopyPlus, Edit2, ExternalLink, Eye, FilePlus, FolderPlus, GitCompare, History, Scissors, Trash2 } from 'lucide-preact';
33
33
 
34
34
  interface ContextMenuProps {
35
35
  x: number;
@@ -61,6 +61,7 @@ function buildRows(options: { isFolder: boolean; hasGitStatus?: boolean; pasteLa
61
61
 
62
62
  if (isFolder) {
63
63
  entries.push({ kind: 'item', id: 'new_file', label: 'New File', icon: <FilePlus size={13} className="text-ink/60" /> });
64
+ entries.push({ kind: 'item', id: 'new_folder', label: 'New Folder', icon: <FolderPlus size={13} className="text-ink/60" /> });
64
65
  entries.push({ kind: 'separator' });
65
66
  } else {
66
67
  entries.push({ kind: 'item', id: 'view', label: 'View', icon: <Eye size={13} className="text-ink/60" /> });
@@ -4,10 +4,12 @@ import { History, RefreshCw, X } from 'lucide-preact';
4
4
  import type { FsActionResponse } from '@/shared/types';
5
5
 
6
6
  interface NewFileModalProps {
7
- /** Folder the file will be created in, relative to the listed root. */
7
+ /** Folder the entry will be created in, relative to the listed root. */
8
8
  folderPath: string;
9
+ /** What the dialog creates — a file or a folder. */
10
+ kind: 'file' | 'folder';
9
11
  value: string;
10
- /** Server's refusal (a name that already exists, a bad character), shown inline. */
12
+ /** Server's refusal (a name that already exists, a bad path), shown inline. */
11
13
  error?: string | null;
12
14
  isLoading: boolean;
13
15
  onChange: (value: string) => void;
@@ -16,21 +18,26 @@ interface NewFileModalProps {
16
18
  }
17
19
 
18
20
  /**
19
- * The "New File" dialog.
21
+ * The "New File" / "New Folder" dialog.
22
+ *
23
+ * The folder is the one that was right-clicked, shown as a fixed prefix so the
24
+ * field's meaning is unambiguous — and the name may carry SEGMENTS
25
+ * (`lib/parse.ts`, `src/nested/x.ts`), which creates every directory on the way
26
+ * in one step. The grammar that keeps that inside the named folder is the
27
+ * server's, so the field does not restate it; it only says what it accepts.
20
28
  *
21
- * Only a name is asked for — the folder is the one that was right-clicked, and
22
- * showing it as a fixed prefix is what makes the field's meaning unambiguous.
23
29
  * The input starts empty (no `.txt` default): a name is what the user has in
24
30
  * mind, and a prefilled suffix is a character to delete before every keystroke
25
31
  * lands. The server's refusal is rendered INLINE rather than as a toast: the
26
32
  * field is still open, and "already exists" is a message about what to type
27
33
  * next, not a notification about something that happened.
28
34
  */
29
- export function FileCreateModal({ folderPath, value, error, isLoading, onChange, onCancel, onSubmit }: NewFileModalProps) {
35
+ export function FileCreateModal({ folderPath, kind, value, error, isLoading, onChange, onCancel, onSubmit }: NewFileModalProps) {
36
+ const label = kind === 'folder' ? 'Folder' : 'File';
30
37
  return createPortal(
31
38
  <div className="fixed inset-0 bg-ink/20 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
32
39
  <form onSubmit={onSubmit} className="bg-paper rounded shadow-xl p-4 w-full max-w-sm border border-ink/10 font-sans">
33
- <h3 className="font-semibold text-ink mb-3 text-sm">New File</h3>
40
+ <h3 className="font-semibold text-ink mb-3 text-sm">New {label}</h3>
34
41
  <div className="flex items-center gap-1 mb-1 text-xs font-mono text-ink/50 min-w-0">
35
42
  <span className="truncate" title={folderPath}>{folderPath || '.'}/</span>
36
43
  </div>
@@ -39,12 +46,15 @@ export function FileCreateModal({ folderPath, value, error, isLoading, onChange,
39
46
  type="text"
40
47
  value={value}
41
48
  onChange={e => onChange(e.currentTarget.value)}
42
- placeholder="file name"
43
- aria-label="New file name"
44
- className={`w-full bg-paper border rounded px-2 py-1.5 text-xs font-mono focus:outline-none mb-2 ${
49
+ placeholder={kind === 'folder' ? 'folder name or nested/path' : 'file name or nested/path'}
50
+ aria-label={`New ${label.toLowerCase()} name`}
51
+ className={`w-full bg-paper border rounded px-2 py-1.5 text-xs font-mono focus:outline-none mb-1 ${
45
52
  error ? 'border-error' : 'border-ink/20 focus:border-ink'
46
53
  }`}
47
54
  />
55
+ <p className="text-[10px] text-ink/40 mb-2">
56
+ Nested paths create the folders they need — <span className="font-mono">lib/parse.ts</span>.
57
+ </p>
48
58
  {error && <p className="text-[11px] text-error mb-2">{error}</p>}
49
59
  <div className="flex justify-end space-x-2 mt-2">
50
60
  <button
@@ -109,13 +119,24 @@ interface RenameModalProps {
109
119
  isFolder: boolean;
110
120
  renameValue: string;
111
121
  originalPath: string;
122
+ /** The server's refusal (an existing destination, a bad path), shown inline. */
123
+ error?: string | null;
112
124
  isLoading: boolean;
113
125
  onChange: (value: string) => void;
114
126
  onCancel: () => void;
115
127
  onSubmit: (e: FormEvent) => void;
116
128
  }
117
129
 
118
- export function FileRenameModal({ isFolder, renameValue, originalPath, isLoading, onChange, onCancel, onSubmit }: RenameModalProps) {
130
+ /**
131
+ * Rename, with the WHOLE path in the field.
132
+ *
133
+ * The field holds the path relative to the listed root, not just the leaf name,
134
+ * because that is what makes a nested destination reachable: `src/a.ts` →
135
+ * `lib/deep/a.ts` both renames the file and moves it, and its parents are
136
+ * created on the way. A leaf-only field would have made that a create-then-
137
+ * delete, which is not a rename.
138
+ */
139
+ export function FileRenameModal({ isFolder, renameValue, originalPath, error, isLoading, onChange, onCancel, onSubmit }: RenameModalProps) {
119
140
  return createPortal(
120
141
  <div className="fixed inset-0 bg-ink/20 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
121
142
  <form onSubmit={onSubmit} className="bg-paper rounded shadow-xl p-4 w-full max-w-sm border border-ink/10 font-sans">
@@ -125,8 +146,15 @@ export function FileRenameModal({ isFolder, renameValue, originalPath, isLoading
125
146
  type="text"
126
147
  value={renameValue}
127
148
  onChange={e => onChange(e.currentTarget.value)}
128
- className="w-full bg-paper border border-ink/20 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:border-ink mb-4"
149
+ aria-label={`Rename ${isFolder ? 'folder' : 'file'} path`}
150
+ className={`w-full bg-paper border rounded px-2 py-1.5 text-xs font-mono focus:outline-none mb-1 ${
151
+ error ? 'border-error' : 'border-ink/20 focus:border-ink'
152
+ }`}
129
153
  />
154
+ <p className="text-[10px] text-ink/40 mb-2">
155
+ Paths are relative to the tree — <span className="font-mono">lib/deep/name.ts</span> moves it too.
156
+ </p>
157
+ {error && <p className="text-[11px] text-error mb-2">{error}</p>}
130
158
  <div className="flex justify-end space-x-2">
131
159
  <button
132
160
  type="button"
@@ -9,9 +9,9 @@
9
9
  *
10
10
  * A row's menu (`FileContextMenu`) offers View, Diff, Rename, Delete — all
11
11
  * things a FILE has. The root has none of them: it is the listing itself, and
12
- * the only things you can ask of it are a new file at the top of the tree and a
13
- * paste into it. Reusing the row menu here would have offered six actions that
14
- * name the wrong target.
12
+ * the only things you can ask of it are a new file or folder at the top of the
13
+ * tree and a paste into it. Reusing the row menu here would have offered six
14
+ * actions that name the wrong target.
15
15
  *
16
16
  * Paste is the one row-menu action the root DOES own: pasting into the
17
17
  * background means pasting into the root folder, which is a real destination —
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { createPortal } from 'preact/compat';
23
23
  import type { RefObject } from 'preact/compat';
24
- import { ClipboardPaste, FilePlus } from 'lucide-preact';
24
+ import { ClipboardPaste, FilePlus, FolderPlus } from 'lucide-preact';
25
25
 
26
26
  interface RootContextMenuProps {
27
27
  x: number;
@@ -36,7 +36,8 @@ interface RootContextMenuProps {
36
36
 
37
37
  export function RootContextMenu({ x, y, pasteLabel, busy, onAction, menuRef }: RootContextMenuProps) {
38
38
  const menuWidth = 190;
39
- const menuHeight = pasteLabel ? 76 : 48;
39
+ // Two create rows, plus paste when the clipboard can land here.
40
+ const menuHeight = pasteLabel ? 104 : 76;
40
41
 
41
42
  const posX = typeof window !== 'undefined' ? Math.max(10, Math.min(x, window.innerWidth - menuWidth - 12)) : x;
42
43
  const posY = typeof window !== 'undefined' ? Math.max(10, Math.min(y, window.innerHeight - menuHeight - 12)) : y;
@@ -61,6 +62,14 @@ export function RootContextMenu({ x, y, pasteLabel, busy, onAction, menuRef }: R
61
62
  <FilePlus size={13} className="text-ink/60" />
62
63
  <span>New File</span>
63
64
  </button>
65
+ <button
66
+ type="button"
67
+ onClick={() => onAction('new_folder')}
68
+ className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center space-x-2 transition-colors"
69
+ >
70
+ <FolderPlus size={13} className="text-ink/60" />
71
+ <span>New Folder</span>
72
+ </button>
64
73
  {pasteLabel && (
65
74
  <button
66
75
  type="button"
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The Files panel's header: the repo picker, Refresh and the filter field.
8
+ *
9
+ * Split out of the panel because it is a self-contained strip of markup with no
10
+ * state of its own — the panel's `index.tsx` sits at the repository's line
11
+ * ceiling, and the tree below it is what the file is for.
12
+ */
13
+
14
+ import { RefreshCw, Search } from 'lucide-preact';
15
+ import { GitRepoPicker } from '@/client/components/workspace/repo-picker';
16
+
17
+ export interface FileExplorerToolbarProps {
18
+ rootPath?: string;
19
+ activeRepo: string;
20
+ repos: string[];
21
+ reposScanning: boolean;
22
+ onRefreshRepos: () => void;
23
+ onSelectRepo: (repo: string) => void;
24
+ searchQuery: string;
25
+ onSearchChange: (value: string) => void;
26
+ onRefresh: () => void;
27
+ /** True while a read is in flight or a topic push just landed. */
28
+ refreshing: boolean;
29
+ }
30
+
31
+ export function FileExplorerToolbar({
32
+ rootPath,
33
+ activeRepo,
34
+ repos,
35
+ reposScanning,
36
+ onRefreshRepos,
37
+ onSelectRepo,
38
+ searchQuery,
39
+ onSearchChange,
40
+ onRefresh,
41
+ refreshing,
42
+ }: FileExplorerToolbarProps) {
43
+ return (
44
+ <div className="p-3 border-b border-ink/10 flex flex-col space-y-2">
45
+ <div className="flex items-center justify-between">
46
+ <GitRepoPicker
47
+ rootPath={rootPath}
48
+ activeRepo={activeRepo}
49
+ onSelectRepo={onSelectRepo}
50
+ repos={repos}
51
+ scanning={reposScanning}
52
+ onRefreshRepos={onRefreshRepos}
53
+ />
54
+ <button
55
+ onClick={onRefresh}
56
+ className="p-1.5 text-ink/40 hover:text-ink hover:bg-ink/5 rounded transition-colors"
57
+ title="Refresh"
58
+ >
59
+ <RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
60
+ </button>
61
+ </div>
62
+ <div className="relative">
63
+ <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink/40" />
64
+ <input
65
+ type="text"
66
+ placeholder="Search files..."
67
+ value={searchQuery}
68
+ onChange={(e) => onSearchChange(e.currentTarget.value)}
69
+ className="w-full bg-canvas border border-ink/20 rounded pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:border-ink transition-colors text-ink placeholder-ink/40"
70
+ />
71
+ </div>
72
+ </div>
73
+ );
74
+ }
@@ -7,6 +7,7 @@ import { getGitStatusInfo, type FolderGitStatusInfo } from '@/shared/lib/fs/git-
7
7
  import { useFileActions } from '@/client/hooks/workspace/file-tree-actions';
8
8
  import { useFileRowDrag } from '@/client/hooks/workspace/file-row-drag';
9
9
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
10
+ import { useLongPress } from '@/client/hooks/ui/long-press';
10
11
  import type { FileClipboard, ClipboardTarget } from '@/client/hooks/workspace/file-clipboard';
11
12
  import type { GitChange } from '@/shared/types/git';
12
13
 
@@ -18,7 +19,12 @@ interface FileTreeItemProps {
18
19
  repo?: string;
19
20
  onLoadChildren?: (path: string) => Promise<void>;
20
21
  onOpenFile?: (file: any) => void;
22
+ /** Re-read the tree — a DELETE has no local answer. */
21
23
  onActionComplete: () => void;
24
+ /** Place a row this row's menu just created, without a reload. */
25
+ onCreated?: (entry: { path: string; kind: 'file' | 'folder' }) => void;
26
+ /** Move a row this row's menu just renamed, without a reload. */
27
+ onRenamed?: (fromPath: string, toPath: string) => void;
22
28
  expandedPaths?: Set<string>;
23
29
  onToggleFolder?: (path: string, open: boolean) => void;
24
30
  gitFileMap?: Map<string, GitChange>;
@@ -31,6 +37,13 @@ interface FileTreeItemProps {
31
37
  onDragStart: (target: ClipboardTarget) => void;
32
38
  onDragEnd: () => void;
33
39
  onDropOn: (target: ClipboardTarget) => void;
40
+ /**
41
+ * True on a device typed into with a finger. A row is a drag SOURCE, and on
42
+ * touch a long press starts a native drag of the whole panel — the reported
43
+ * "it selects all of the UI" — while no `contextmenu` reaches this row. The
44
+ * native drag is dropped there and the press opens the menu instead.
45
+ */
46
+ touch?: boolean;
34
47
  }
35
48
 
36
49
  /**
@@ -63,6 +76,8 @@ export function FileTreeItem({
63
76
  onLoadChildren,
64
77
  onOpenFile,
65
78
  onActionComplete,
79
+ onCreated,
80
+ onRenamed,
66
81
  expandedPaths,
67
82
  onToggleFolder,
68
83
  gitFileMap,
@@ -72,6 +87,7 @@ export function FileTreeItem({
72
87
  onDragStart,
73
88
  onDragEnd,
74
89
  onDropOn,
90
+ touch = false,
75
91
  }: FileTreeItemProps) {
76
92
  const isFolder = file.type === 'folder';
77
93
  const [isOpen, setIsOpen] = useState(false);
@@ -115,13 +131,23 @@ export function FileTreeItem({
115
131
  diff: { staged: gitChange?.staged || (gitChange?.status ? gitChange.status[0] !== ' ' && gitChange.status[0] !== '?' : false), status: gitStatusInfo?.charStatus },
116
132
  onOpenFile,
117
133
  onActionComplete,
134
+ onCreated,
135
+ onRenamed,
118
136
  clipboard,
119
137
  isFolder,
120
138
  });
121
139
  useOnClickOutside(actions.contextMenuRef, actions.closeContextMenu);
122
140
 
141
+ // A press-and-hold opens the same menu a right-click does; the click the same
142
+ // gesture produces is swallowed, or lifting the finger would toggle the row.
143
+ const longPress = useLongPress({
144
+ enabled: touch,
145
+ onLongPress: (x, y) => actions.openContextMenu(x, y),
146
+ });
147
+
123
148
  const handleToggle = (e: globalThis.MouseEvent) => {
124
149
  e.stopPropagation();
150
+ if (longPress.consume()) return;
125
151
  if (isFolder) {
126
152
  const next = !actualIsOpen;
127
153
  setIsOpen(next);
@@ -175,16 +201,20 @@ export function FileTreeItem({
175
201
  return (
176
202
  <div>
177
203
  <div
178
- className={`flex items-center space-x-1.5 py-1 px-2 hover:bg-ink/5 cursor-pointer rounded group ${
204
+ className={`flex items-center space-x-1.5 py-1 px-2 hover:bg-ink/5 cursor-pointer rounded group select-none ${
179
205
  drag.isDragging ? 'opacity-40' : ''
180
206
  } ${drag.acceptsDrop ? 'bg-info/10 ring-1 ring-info/40 ring-inset' : ''}`}
181
- draggable
207
+ draggable={!touch}
182
208
  onClick={handleToggle}
183
209
  onContextMenu={handleContextMenu}
184
210
  onDragStart={drag.onDragStart}
185
211
  onDragEnd={drag.onDragEnd}
186
212
  onDragOver={drag.onDragOver}
187
213
  onDrop={drag.onDrop}
214
+ onTouchStart={longPress.onTouchStart}
215
+ onTouchMove={longPress.onTouchMove}
216
+ onTouchEnd={longPress.onTouchEnd}
217
+ onTouchCancel={longPress.onTouchCancel}
188
218
  >
189
219
  {isFolder ? (
190
220
  actualIsOpen ? <ChevronDown size={12} className="flex-shrink-0 text-ink/40" /> : <ChevronRight size={12} className="flex-shrink-0 text-ink/40" />
@@ -228,7 +258,7 @@ export function FileTreeItem({
228
258
  <div className="ml-3 border-l border-ink/10 pl-1">
229
259
  {children.map((child: any) => (
230
260
  <FileTreeItem
231
- key={child.id}
261
+ key={child.path}
232
262
  file={child}
233
263
  rootPath={rootPath}
234
264
  basePath={basePath}
@@ -236,6 +266,8 @@ export function FileTreeItem({
236
266
  onLoadChildren={onLoadChildren}
237
267
  onOpenFile={onOpenFile}
238
268
  onActionComplete={onActionComplete}
269
+ onCreated={onCreated}
270
+ onRenamed={onRenamed}
239
271
  expandedPaths={expandedPaths}
240
272
  onToggleFolder={onToggleFolder}
241
273
  gitFileMap={gitFileMap}
@@ -245,6 +277,7 @@ export function FileTreeItem({
245
277
  onDragStart={onDragStart}
246
278
  onDragEnd={onDragEnd}
247
279
  onDropOn={onDropOn}
280
+ touch={touch}
248
281
  />
249
282
  ))}
250
283
  </div>
@@ -265,6 +298,7 @@ export function FileTreeItem({
265
298
  {mounted && actions.showCreateModal && (
266
299
  <FileCreateModal
267
300
  folderPath={file.path}
301
+ kind={actions.createKind}
268
302
  value={actions.createName}
269
303
  error={actions.createError}
270
304
  isLoading={actions.busy}
@@ -289,6 +323,7 @@ export function FileTreeItem({
289
323
  isFolder={isFolder}
290
324
  renameValue={actions.renameValue}
291
325
  originalPath={file.path}
326
+ error={actions.renameError}
292
327
  isLoading={actions.busy}
293
328
  onChange={actions.setRenameValue}
294
329
  onCancel={actions.closeRename}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The Files panel's filter: keep a node when its own name matches, or when a
8
+ * descendant does.
9
+ *
10
+ * A matching FOLDER keeps its whole subtree (its name is the answer, and hiding
11
+ * its contents would make the match useless), while a folder that only matches
12
+ * through its children keeps just the children that do. Either way the row is
13
+ * forced open, because a match inside a collapsed folder is a match the user
14
+ * cannot see.
15
+ *
16
+ * Extracted from the panel so the render file stays under the line ceiling.
17
+ */
18
+
19
+ import type { FsNode } from '@/shared/types';
20
+
21
+ export function filterFileTree(tree: FsNode[], query: string): FsNode[] {
22
+ const lowerQuery = query.trim().toLowerCase();
23
+ if (!lowerQuery) return tree;
24
+
25
+ const filterNode = (node: FsNode): FsNode | null => {
26
+ const isMatch = node.name.toLowerCase().includes(lowerQuery);
27
+
28
+ if (node.type === 'folder') {
29
+ const filteredChildren = (node.children || [])
30
+ .map(filterNode)
31
+ .filter((child): child is FsNode => child !== null);
32
+
33
+ if (isMatch || filteredChildren.length > 0) {
34
+ return {
35
+ ...node,
36
+ children: isMatch && filteredChildren.length === 0 ? node.children : filteredChildren,
37
+ forceExpanded: true,
38
+ };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ return isMatch ? node : null;
44
+ };
45
+
46
+ return tree.map(filterNode).filter((node): node is FsNode => node !== null);
47
+ }