@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- 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=
|
|
82
|
-
<div className=
|
|
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=
|
|
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=
|
|
161
|
-
<div className=
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
43
|
-
aria-label=
|
|
44
|
-
className={`w-full bg-paper border rounded px-2 py-1.5 text-xs font-mono focus:outline-none mb-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
* paste into it. Reusing the row menu here would have offered six
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|