@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/client/tailwind.css
CHANGED
|
@@ -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 {
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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('
|
|
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
|
+
}
|