@kabmo-labs/kabmo 0.6.1 → 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-aq0e78gv.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 +74 -1
- 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 +126 -50
- package/src/server/lib/updates/shell-release.ts +139 -88
- 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
|
@@ -2,85 +2,68 @@
|
|
|
2
2
|
* Which published native-shell artifact a browser or an installed shell should
|
|
3
3
|
* download, and whether the installed one is behind.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* not `/releases/latest`, which answers the newest release of ANY kind (see
|
|
10
|
-
* `fetchShellRelease`).
|
|
5
|
+
* No shell is an npm package (see `.github/workflows/publish-{android,ios,macos,
|
|
6
|
+
* windows,linux}.yml`), so the npm probe that answers for the app and for `omp`
|
|
7
|
+
* cannot answer here. The only record of a published shell is a GitHub release
|
|
8
|
+
* asset.
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
* asset naming differs. Two copies is how one of them starts reading the release
|
|
14
|
-
* tag, or caching under a slot the other overwrites.
|
|
10
|
+
* ## Two reads, because one of them has a quota
|
|
15
11
|
*
|
|
16
|
-
*
|
|
12
|
+
* The REST API is the precise read and the FIRST one tried. It is also
|
|
13
|
+
* unauthenticated at 60 requests an hour **per source IP**, and a phone on mobile
|
|
14
|
+
* data shares that IP with an entire carrier NAT — so `shell-pages` is the second
|
|
15
|
+
* path, and it is why the button appears at all on a bad day. See that module's
|
|
16
|
+
* own comment for the measured failure.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* list rather than built as `releases/latest/download/<name>`, because that
|
|
20
|
-
* path needs the asset name to match exactly and the name carries the tag
|
|
21
|
-
* (`kabmo-android-v1.2.3.apk`) — a name built here would be a second copy of
|
|
22
|
-
* the workflow's naming rule, and the first release that renames it 404s.
|
|
23
|
-
* - `latest` is the version that asset ships, read from the asset name. The
|
|
24
|
-
* release TAG cannot be used: the artifact is attached to the app's release,
|
|
25
|
-
* whose tag names the SERVER version, and the two move independently.
|
|
18
|
+
* ## What travels back
|
|
26
19
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
20
|
+
* - `url` is what to download. For the mobile pair it is the artifact itself (an
|
|
21
|
+
* APK the shell installs, an IPA the release page explains). For the DESKTOP
|
|
22
|
+
* trio it is the RELEASE PAGE, deliberately: every desktop artifact is
|
|
23
|
+
* per-architecture (`Kabmo-0.2.0-mac-arm64.zip`), and this server cannot know
|
|
24
|
+
* which architecture the window asking is running on — it may be a remote
|
|
25
|
+
* console on another machine entirely. Naming one arch's file would hand half
|
|
26
|
+
* the readers a build that will not launch.
|
|
27
|
+
* - `latest` is the version that asset ships, read from the asset NAME (see
|
|
28
|
+
* `shell-assets`). The release TAG cannot be used: the artifact is attached to
|
|
29
|
+
* the app's release, whose tag names the SERVER version.
|
|
30
|
+
*
|
|
31
|
+
* Failure is a normal answer here (`null`), never a throw: an offline box must
|
|
32
|
+
* not take the sidebar down, and a button that appears because a probe failed
|
|
33
|
+
* would offer a download that does not exist.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
import { REPO } from '@/server/lib/updates/release-url';
|
|
37
|
+
import { SHELL_ASSETS, versionFromAssetName } from '@/server/lib/updates/shell-assets';
|
|
38
|
+
import { findAssetInPages } from '@/server/lib/updates/shell-pages';
|
|
33
39
|
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
34
40
|
|
|
35
41
|
/** One published shell artifact, as the console needs it. */
|
|
36
42
|
export interface ShellRelease {
|
|
37
|
-
/** Asset download URL, or
|
|
43
|
+
/** Asset download URL, or the release page for a per-arch desktop build. */
|
|
38
44
|
url: string | null;
|
|
39
|
-
/** Version the asset name declares (`
|
|
45
|
+
/** Version the asset name declares (`Kabmo-1.2.3-android.apk` → `1.2.3`). */
|
|
40
46
|
latest: string | null;
|
|
41
47
|
/** The release page, for a "what changed" link. */
|
|
42
48
|
releaseUrl: string | null;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
/** How each shell's asset is named by its publish workflow. */
|
|
46
|
-
export const SHELL_ASSETS: Record<ShellKind, { prefix: string; extension: string }> = {
|
|
47
|
-
android: { prefix: 'kabmo-android-', extension: '.apk' },
|
|
48
|
-
ios: { prefix: 'kabmo-ios-', extension: '.ipa' },
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* `kabmo-android-v1.2.3.apk` → `1.2.3` (and the `.ipa` twin).
|
|
53
|
-
*
|
|
54
|
-
* The `v` is optional because a hand-uploaded asset may omit it, and a version
|
|
55
|
-
* that cannot be read is not evidence of an update — the caller treats null as
|
|
56
|
-
* "no new version".
|
|
57
|
-
*/
|
|
58
|
-
export function versionFromAssetName(kind: ShellKind, name: string): string | null {
|
|
59
|
-
const { prefix, extension } = SHELL_ASSETS[kind];
|
|
60
|
-
if (!name.startsWith(prefix) || !name.endsWith(extension)) return null;
|
|
61
|
-
const middle = name.slice(prefix.length, -extension.length);
|
|
62
|
-
const version = middle.startsWith('v') ? middle.slice(1) : middle;
|
|
63
|
-
return /^\d+\.\d+\.\d+/.test(version) ? version : null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
51
|
/**
|
|
67
52
|
* The newest release that actually carries this shell's artifact.
|
|
68
53
|
*
|
|
69
54
|
* NOT `/releases/latest`, which answers the newest release of ANY kind. This
|
|
70
|
-
* repository publishes
|
|
71
|
-
* released FIRST (`packages → marketplace → android →
|
|
55
|
+
* repository publishes several artifacts from one push, and the packages are
|
|
56
|
+
* released FIRST (`packages → marketplace → android → … → app` in
|
|
72
57
|
* `release.yml`), so their `kabmo-sdk/v*` / `kabmo-ui/v*` releases are newer
|
|
73
|
-
* than the app release that carries the
|
|
58
|
+
* than the app release that carries the shells. `@semantic-release/github` runs
|
|
74
59
|
* in both pipelines, so those are real releases — measured against a stub that
|
|
75
60
|
* answered a package release: `/releases/latest` produced
|
|
76
61
|
* `{url: null, latest: null}` and the sidebar's button disappeared even though
|
|
77
62
|
* the APK sat on the release directly below.
|
|
78
63
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* unauthenticated reads an hour per address, and the sidebar asks on every
|
|
83
|
-
* mount.
|
|
64
|
+
* Cached for five minutes on `globalThis` for the same reason the database
|
|
65
|
+
* handle is anchored there: `bun --hot` re-evaluates this module while the cache
|
|
66
|
+
* it would otherwise hold lives on.
|
|
84
67
|
*/
|
|
85
68
|
export async function fetchShellRelease(
|
|
86
69
|
kind: ShellKind,
|
|
@@ -90,25 +73,44 @@ export async function fetchShellRelease(
|
|
|
90
73
|
const now = Date.now();
|
|
91
74
|
if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
|
|
92
75
|
|
|
76
|
+
const headers = gitHubHeaders();
|
|
77
|
+
const resolved =
|
|
78
|
+
(await readFromApi(kind, fetchImpl, headers)) ?? (await readFromPages(kind, fetchImpl, headers));
|
|
79
|
+
if (!resolved) return cache.entry ?? null;
|
|
80
|
+
|
|
81
|
+
cache.entry = resolved;
|
|
82
|
+
cache.at = now;
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How many releases to look back through for the artifact. */
|
|
87
|
+
const RELEASE_SCAN = 20;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The REST read, or null when it did not answer with a usable release list.
|
|
91
|
+
*
|
|
92
|
+
* A non-2xx is not an error to report — it is the quota, and the caller has a
|
|
93
|
+
* second path.
|
|
94
|
+
*/
|
|
95
|
+
async function readFromApi(
|
|
96
|
+
kind: ShellKind,
|
|
97
|
+
fetchImpl: typeof fetch,
|
|
98
|
+
headers: Record<string, string>,
|
|
99
|
+
): Promise<ShellRelease | null> {
|
|
93
100
|
try {
|
|
94
101
|
const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases?per_page=${RELEASE_SCAN}`, {
|
|
95
|
-
headers: {
|
|
96
|
-
accept: 'application/vnd.github+json',
|
|
97
|
-
// GitHub rejects a request with no User-Agent.
|
|
98
|
-
'user-agent': 'kabmo-console',
|
|
99
|
-
},
|
|
102
|
+
headers: { accept: 'application/vnd.github+json', ...headers },
|
|
100
103
|
});
|
|
101
|
-
if (!response.ok) return
|
|
104
|
+
if (!response.ok) return null;
|
|
102
105
|
|
|
103
106
|
const body = (await response.json()) as unknown;
|
|
104
107
|
// A body that is not the release list is a failed probe, not an empty
|
|
105
108
|
// repository: answering `{url: null, latest: null}` here would be
|
|
106
|
-
// indistinguishable from "no release carries an artifact"
|
|
107
|
-
|
|
108
|
-
if (!Array.isArray(body)) return cache.entry ?? null;
|
|
109
|
+
// indistinguishable from "no release carries an artifact".
|
|
110
|
+
if (!Array.isArray(body)) return null;
|
|
109
111
|
|
|
110
112
|
// Drafts are not published and prereleases are not what a console offers an
|
|
111
|
-
// installed shell
|
|
113
|
+
// installed shell.
|
|
112
114
|
const published = body.filter(
|
|
113
115
|
(entry): entry is Record<string, unknown> =>
|
|
114
116
|
entry !== null && typeof entry === 'object' && entry.draft !== true && entry.prerelease !== true,
|
|
@@ -118,43 +120,73 @@ export async function fetchShellRelease(
|
|
|
118
120
|
.map((entry) => ({ entry, asset: shellAsset(kind, entry.assets) }))
|
|
119
121
|
.find((candidate) => candidate.asset !== null);
|
|
120
122
|
|
|
121
|
-
if (!release?.asset) {
|
|
122
|
-
const fallback: ShellRelease = { url: null, latest: null, releaseUrl: null };
|
|
123
|
-
cache.entry = fallback;
|
|
124
|
-
cache.at = now;
|
|
125
|
-
return fallback;
|
|
126
|
-
}
|
|
123
|
+
if (!release?.asset) return { url: null, latest: null, releaseUrl: null };
|
|
127
124
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
return resolvedRelease(kind, {
|
|
126
|
+
name: String(release.asset.name),
|
|
127
|
+
downloadUrl:
|
|
128
|
+
typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
|
|
131
129
|
releaseUrl: typeof release.entry.html_url === 'string' ? release.entry.html_url : null,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
cache.entry = resolved;
|
|
135
|
-
cache.at = now;
|
|
136
|
-
return resolved;
|
|
130
|
+
});
|
|
137
131
|
} catch {
|
|
138
|
-
|
|
139
|
-
// momentary outage must not remove a download button that was valid.
|
|
140
|
-
return cache.entry ?? null;
|
|
132
|
+
return null;
|
|
141
133
|
}
|
|
142
134
|
}
|
|
143
135
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
136
|
+
/** The public-pages read, wrapped so a failure is the caller's `null`. */
|
|
137
|
+
async function readFromPages(
|
|
138
|
+
kind: ShellKind,
|
|
139
|
+
fetchImpl: typeof fetch,
|
|
140
|
+
headers: Record<string, string>,
|
|
141
|
+
): Promise<ShellRelease | null> {
|
|
142
|
+
try {
|
|
143
|
+
const found = await findAssetInPages(kind, fetchImpl, headers);
|
|
144
|
+
if (found.kind === 'unreachable') return null;
|
|
145
|
+
if (found.kind === 'none') return { url: null, latest: null, releaseUrl: null };
|
|
146
|
+
return resolvedRelease(kind, { ...found.asset, releaseUrl: found.releaseUrl });
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
146
151
|
|
|
147
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* The asset of a release that names this shell's artifact, or null.
|
|
154
|
+
*
|
|
155
|
+
* The CURRENT scheme wins over the legacy one, and that is not cosmetic. A
|
|
156
|
+
* release that carries both — which happens the moment a rename lands and
|
|
157
|
+
* `--clobber` has not yet replaced the old asset, or a hand-upload adds one
|
|
158
|
+
* beside the other — would otherwise resolve to whichever the asset list
|
|
159
|
+
* happened to order first. Measured: a release holding both
|
|
160
|
+
* `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0`,
|
|
161
|
+
* i.e. the console offered an update to a version it already had and hid the
|
|
162
|
+
* newer one.
|
|
163
|
+
*/
|
|
148
164
|
function shellAsset(kind: ShellKind, assets: unknown): Record<string, unknown> | null {
|
|
149
165
|
if (!Array.isArray(assets)) return null;
|
|
150
166
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
167
|
+
const named = assets.filter(
|
|
168
|
+
(entry): entry is Record<string, unknown> =>
|
|
169
|
+
entry !== null &&
|
|
170
|
+
typeof entry === 'object' &&
|
|
171
|
+
typeof (entry as Record<string, unknown>).name === 'string' &&
|
|
172
|
+
versionFromAssetName(kind, String((entry as Record<string, unknown>).name)) !== null,
|
|
173
|
+
);
|
|
156
174
|
|
|
157
|
-
return null;
|
|
175
|
+
return named.find((asset) => SHELL_ASSETS[kind].current.test(String(asset.name))) ?? named[0] ?? null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Turn one resolved asset into the payload, applying the per-kind link rule. */
|
|
179
|
+
function resolvedRelease(
|
|
180
|
+
kind: ShellKind,
|
|
181
|
+
asset: { name: string; downloadUrl: string | null; releaseUrl: string | null },
|
|
182
|
+
): ShellRelease {
|
|
183
|
+
return {
|
|
184
|
+
// A per-arch desktop build cannot be named from here — see the module
|
|
185
|
+
// comment — so the link is the page that lists every architecture.
|
|
186
|
+
url: SHELL_ASSETS[kind].directDownload ? asset.downloadUrl : asset.releaseUrl,
|
|
187
|
+
latest: versionFromAssetName(kind, asset.name),
|
|
188
|
+
releaseUrl: asset.releaseUrl,
|
|
189
|
+
};
|
|
158
190
|
}
|
|
159
191
|
|
|
160
192
|
const CACHE_MS = 5 * 60_000;
|
|
@@ -172,6 +204,25 @@ function githubApiBase(): string {
|
|
|
172
204
|
return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
|
|
173
205
|
}
|
|
174
206
|
|
|
207
|
+
/**
|
|
208
|
+
* A token, when the operator has one, which lifts the API quota from 60 an hour
|
|
209
|
+
* to 5000 — the difference between the precise read working on a shared address
|
|
210
|
+
* and falling through to the pages every time.
|
|
211
|
+
*
|
|
212
|
+
* Read from the environment rather than a setting: it is a deployment fact, it
|
|
213
|
+
* must never reach the browser, and `kabmo serve` already passes its own
|
|
214
|
+
* environment through.
|
|
215
|
+
*/
|
|
216
|
+
function gitHubHeaders(): Record<string, string> {
|
|
217
|
+
const token = (Bun.env.KABMO_GITHUB_TOKEN ?? Bun.env.GITHUB_TOKEN ?? '').trim();
|
|
218
|
+
const headers: Record<string, string> = {
|
|
219
|
+
// GitHub rejects a request with no User-Agent.
|
|
220
|
+
'user-agent': 'kabmo-console',
|
|
221
|
+
};
|
|
222
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
223
|
+
return headers;
|
|
224
|
+
}
|
|
225
|
+
|
|
175
226
|
interface CacheSlot {
|
|
176
227
|
entry: ShellRelease | null;
|
|
177
228
|
at: number;
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* The Files panel's "New file" branch.
|
|
8
8
|
*
|
|
9
|
-
* Two rules are load-bearing and neither is visible from the happy path
|
|
10
|
-
* name may
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Two rules are load-bearing and neither is visible from the happy path. The
|
|
10
|
+
* name may carry SEGMENTS (that is the nested-path grammar, and its refusals
|
|
11
|
+
* live in `action-nested.test.ts`), and an existing file is REFUSED rather
|
|
12
|
+
* than truncated — the second is the one a naive `Bun.write` gets wrong:
|
|
13
|
+
* `Bun.write` creates or truncates, so a name that collides with a real file
|
|
14
|
+
* would silently blank it.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
@@ -34,7 +34,10 @@ async function create(folderPath: string, name: string) {
|
|
|
34
34
|
form.append('path', folderPath);
|
|
35
35
|
form.append('name', name);
|
|
36
36
|
const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
|
|
37
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
status: res.status,
|
|
39
|
+
body: (await res.json()) as { success?: boolean; kind?: string; path?: string; error?: string },
|
|
40
|
+
};
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
describe('fs create', () => {
|
|
@@ -43,7 +46,7 @@ describe('fs create', () => {
|
|
|
43
46
|
const { status, body } = await create('tmp-create-probe', 'notes.md');
|
|
44
47
|
|
|
45
48
|
expect(status).toBe(200);
|
|
46
|
-
expect(body).toEqual({ success: true, path: 'tmp-create-probe/notes.md' });
|
|
49
|
+
expect(body).toEqual({ success: true, kind: 'file', path: 'tmp-create-probe/notes.md' });
|
|
47
50
|
expect(fs.readFileSync(path.join(DIR, 'notes.md'), 'utf8')).toBe('');
|
|
48
51
|
});
|
|
49
52
|
|
|
@@ -67,12 +70,14 @@ describe('fs create', () => {
|
|
|
67
70
|
expect(fs.readFileSync(path.join(DIR, 'keep.txt'), 'utf8')).toBe('important');
|
|
68
71
|
});
|
|
69
72
|
|
|
70
|
-
test('
|
|
73
|
+
test('a path separator is a NESTED path, and `..` is still refused', async () => {
|
|
71
74
|
fs.mkdirSync(DIR, { recursive: true });
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
// A forward or back separator names a nested destination, which the dialog
|
|
76
|
+
// is for — the escape itself is the `..` segment, and that stays refused.
|
|
77
|
+
expect((await create('tmp-create-probe', 'sub/inside.txt')).status).toBe(200);
|
|
78
|
+
expect((await create('tmp-create-probe', 'sub\\also-inside.txt')).status).toBe(200);
|
|
79
|
+
expect((await create('tmp-create-probe', '../escape.txt')).status).toBe(400);
|
|
80
|
+
expect(fs.existsSync(path.join(DIR, 'sub/inside.txt'))).toBe(true);
|
|
76
81
|
expect(fs.existsSync(path.join(process.cwd(), 'escape.txt'))).toBe(false);
|
|
77
82
|
});
|
|
78
83
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nested paths at the route: a create and a rename that name SEGMENTS.
|
|
8
|
+
*
|
|
9
|
+
* The Files panel's dialogs used to accept one segment, so `lib/parse.ts` was
|
|
10
|
+
* unreachable from the UI — the user had to create `lib`, expand it, then the
|
|
11
|
+
* file. Both actions now take a relative path, and the rules that keep that
|
|
12
|
+
* inside the folder the user named are asserted here because they are the whole
|
|
13
|
+
* reason a nested path is safe:
|
|
14
|
+
*
|
|
15
|
+
* - a `..` SEGMENT is refused even when it would land back inside the root
|
|
16
|
+
* (`a/../b.ts` resolves there), so the refusal is about the path the user
|
|
17
|
+
* typed rather than about where it happened to land;
|
|
18
|
+
* - an ABSOLUTE path is refused, because `path.join(dir, abs)` discards the
|
|
19
|
+
* folder the dialog showed;
|
|
20
|
+
* - a rename resolves against the LISTED root, not the workspace root — a
|
|
21
|
+
* rename inside a nested repo used to resolve to a path outside the repo;
|
|
22
|
+
* - a rename never overwrites an existing destination, and never moves a folder
|
|
23
|
+
* into its own subtree.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { action } from '@/server/routes/fs/action';
|
|
30
|
+
|
|
31
|
+
/** Probes live in the repo root — the one directory the root allow-list always accepts. */
|
|
32
|
+
const DIR = path.join(process.cwd(), 'tmp-nested-probe');
|
|
33
|
+
|
|
34
|
+
function reset(files: Record<string, string> = {}): void {
|
|
35
|
+
fs.rmSync(DIR, { recursive: true, force: true });
|
|
36
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
37
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
38
|
+
const target = path.join(DIR, rel);
|
|
39
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
40
|
+
fs.writeFileSync(target, content);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
fs.rmSync(DIR, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function post(fields: Record<string, string>) {
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.append('root', process.cwd());
|
|
51
|
+
for (const [key, value] of Object.entries(fields)) form.append(key, value);
|
|
52
|
+
const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
|
|
53
|
+
return { status: res.status, body: (await res.json()) as { success?: boolean; kind?: string; path?: string; previousPath?: string; error?: string } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('fs create with a nested path', () => {
|
|
57
|
+
test('creates every directory that leads to the file', async () => {
|
|
58
|
+
reset();
|
|
59
|
+
|
|
60
|
+
const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/deep/parse.ts' });
|
|
61
|
+
|
|
62
|
+
expect(status).toBe(200);
|
|
63
|
+
expect(body).toEqual({ success: true, kind: 'file', path: 'tmp-nested-probe/lib/deep/parse.ts' });
|
|
64
|
+
expect(fs.readFileSync(path.join(DIR, 'lib/deep/parse.ts'), 'utf8')).toBe('');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('creates a FOLDER when asked, and reports the kind back', async () => {
|
|
68
|
+
reset();
|
|
69
|
+
|
|
70
|
+
const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'a/b', kind: 'folder' });
|
|
71
|
+
|
|
72
|
+
expect(status).toBe(200);
|
|
73
|
+
expect(body).toEqual({ success: true, kind: 'folder', path: 'tmp-nested-probe/a/b' });
|
|
74
|
+
expect(fs.statSync(path.join(DIR, 'a/b')).isDirectory()).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('normalizes a backslash and repeated separators into one path', async () => {
|
|
78
|
+
reset();
|
|
79
|
+
|
|
80
|
+
const { body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'x\\\\y//z.ts' });
|
|
81
|
+
|
|
82
|
+
expect(body.path).toBe('tmp-nested-probe/x/y/z.ts');
|
|
83
|
+
expect(fs.existsSync(path.join(DIR, 'x/y/z.ts'))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('refuses a .. segment even when it would land inside the root', async () => {
|
|
87
|
+
reset({ 'keep/.gitkeep': '' });
|
|
88
|
+
|
|
89
|
+
// `keep/../escape.ts` resolves inside the probe directory, so a containment
|
|
90
|
+
// check alone would accept it — the refusal is about the path as typed.
|
|
91
|
+
const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'keep/../escape.ts' });
|
|
92
|
+
|
|
93
|
+
expect(status).toBe(400);
|
|
94
|
+
expect(body.error).toContain('..');
|
|
95
|
+
expect(fs.existsSync(path.join(DIR, 'escape.ts'))).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('refuses an absolute path', async () => {
|
|
99
|
+
reset();
|
|
100
|
+
|
|
101
|
+
for (const name of ['/tmp/absolute.ts', 'C:/absolute.ts', 'C:relative.ts']) {
|
|
102
|
+
expect((await post({ actionType: 'create', path: 'tmp-nested-probe', name })).status).toBe(400);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('refuses a nested path whose final entry already exists', async () => {
|
|
107
|
+
reset({ 'lib/parse.ts': 'keep me' });
|
|
108
|
+
|
|
109
|
+
const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/parse.ts' });
|
|
110
|
+
|
|
111
|
+
expect(status).toBe(409);
|
|
112
|
+
expect(body.error).toContain('already exists');
|
|
113
|
+
expect(fs.readFileSync(path.join(DIR, 'lib/parse.ts'), 'utf8')).toBe('keep me');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('fs rename with a nested path', () => {
|
|
118
|
+
test('renames into a new nested folder, creating its parents', async () => {
|
|
119
|
+
reset({ 'a.ts': 'A' });
|
|
120
|
+
|
|
121
|
+
const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/lib/deep/a.ts' });
|
|
122
|
+
|
|
123
|
+
expect(status).toBe(200);
|
|
124
|
+
expect(body).toEqual({ success: true, path: 'tmp-nested-probe/lib/deep/a.ts', previousPath: 'tmp-nested-probe/a.ts' });
|
|
125
|
+
expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(false);
|
|
126
|
+
expect(fs.readFileSync(path.join(DIR, 'lib/deep/a.ts'), 'utf8')).toBe('A');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a plain rename inside one folder keeps its siblings', async () => {
|
|
130
|
+
reset({ 'src/a.ts': 'A', 'src/b.ts': 'B' });
|
|
131
|
+
|
|
132
|
+
const { body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src/a.ts', newPath: 'tmp-nested-probe/src/c.ts' });
|
|
133
|
+
|
|
134
|
+
expect(body.path).toBe('tmp-nested-probe/src/c.ts');
|
|
135
|
+
expect(fs.existsSync(path.join(DIR, 'src/b.ts'))).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('refuses an existing destination instead of clobbering it', async () => {
|
|
139
|
+
reset({ 'a.ts': 'A', 'b.ts': 'B' });
|
|
140
|
+
|
|
141
|
+
const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/b.ts' });
|
|
142
|
+
|
|
143
|
+
expect(status).toBe(409);
|
|
144
|
+
expect(body.error).toContain('already exists');
|
|
145
|
+
expect(fs.readFileSync(path.join(DIR, 'b.ts'), 'utf8')).toBe('B');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('refuses moving a folder into its own subtree', async () => {
|
|
149
|
+
reset({ 'src/a.ts': 'A' });
|
|
150
|
+
|
|
151
|
+
const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src', newPath: 'tmp-nested-probe/src/deep' });
|
|
152
|
+
|
|
153
|
+
expect(status).toBe(400);
|
|
154
|
+
expect(body.error).toContain('inside itself');
|
|
155
|
+
expect(fs.existsSync(path.join(DIR, 'src/a.ts'))).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('refuses a .. segment and an absolute destination', async () => {
|
|
159
|
+
reset({ 'a.ts': 'A' });
|
|
160
|
+
|
|
161
|
+
expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/../out.ts' })).status).toBe(400);
|
|
162
|
+
expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: '/tmp/out.ts' })).status).toBe(400);
|
|
163
|
+
expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('renaming to the same path is a no-op that still reports both ends', async () => {
|
|
167
|
+
reset({ 'a.ts': 'A' });
|
|
168
|
+
|
|
169
|
+
const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/a.ts' });
|
|
170
|
+
|
|
171
|
+
expect(status).toBe(200);
|
|
172
|
+
expect(body.path).toBe('tmp-nested-probe/a.ts');
|
|
173
|
+
expect(fs.readFileSync(path.join(DIR, 'a.ts'), 'utf8')).toBe('A');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a missing source is refused rather than creating an empty entry', async () => {
|
|
177
|
+
reset();
|
|
178
|
+
|
|
179
|
+
const { status } = await post({ actionType: 'rename', path: 'tmp-nested-probe/gone.ts', newPath: 'tmp-nested-probe/here.ts' });
|
|
180
|
+
|
|
181
|
+
expect(status).toBe(404);
|
|
182
|
+
expect(fs.existsSync(path.join(DIR, 'here.ts'))).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -3,10 +3,11 @@ import { errorResponse } from '@/server/lib/route-adapter';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { isMockMode } from '@/server/mock.server';
|
|
6
|
-
import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
|
|
6
|
+
import { getDefaultFsRoot, isWithinRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
|
|
7
7
|
import { runShell } from '@/server/lib/fs/shell';
|
|
8
8
|
import { revealInFileManager } from '@/server/lib/fs/reveal';
|
|
9
|
-
import { duplicateEntry, nextDuplicateName, pasteEntry } from '@/server/lib/fs/transfer';
|
|
9
|
+
import { duplicateEntry, nextDuplicateName, pasteEntry, toRelPath } from '@/server/lib/fs/transfer';
|
|
10
|
+
import { normalizeRelativePath } from '@/server/lib/fs/relative-path';
|
|
10
11
|
import { transferRefusal } from '@/shared/lib/fs/clipboard';
|
|
11
12
|
import { toDiskText } from '@/shared/lib/code/line-endings';
|
|
12
13
|
|
|
@@ -104,13 +105,15 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
104
105
|
await Bun.write(fullPath, content);
|
|
105
106
|
return json({ success: true });
|
|
106
107
|
} else if (actionType === 'create') {
|
|
107
|
-
// The Files panel's "New file": an empty
|
|
108
|
-
// right-clicked.
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
// The Files panel's "New file"/"New folder": an empty entry beside the
|
|
109
|
+
// folder that was right-clicked. The NAME may carry segments
|
|
110
|
+
// (`lib/parse.ts`), so one dialog creates a nested path and every
|
|
111
|
+
// intermediate directory with it — the grammar that keeps it inside the
|
|
112
|
+
// named folder lives in `normalizeRelativePath`, not in a slash test here.
|
|
113
|
+
const kind = formData.get('kind') === 'folder' ? 'folder' : 'file';
|
|
114
|
+
const name = normalizeRelativePath((formData.get('name') as string) ?? '');
|
|
115
|
+
if (!name) {
|
|
116
|
+
return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
|
|
114
117
|
}
|
|
115
118
|
const target = resolveWithinRoot(scopedRoot, path.join(filePath || '.', name));
|
|
116
119
|
if (!target) {
|
|
@@ -122,21 +125,49 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
122
125
|
if ((await Bun.file(target).stat().catch(() => null)) !== null) {
|
|
123
126
|
return json({ error: `${name} already exists` }, { status: 409 });
|
|
124
127
|
}
|
|
125
|
-
await fs.promises.mkdir(
|
|
126
|
-
|
|
128
|
+
if (kind === 'folder') await fs.promises.mkdir(target, { recursive: true });
|
|
129
|
+
else {
|
|
130
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
131
|
+
await Bun.write(target, '');
|
|
132
|
+
}
|
|
127
133
|
// Relative to the LISTED root, which is the identity the tree renders by.
|
|
128
|
-
return json({ success: true, path:
|
|
134
|
+
return json({ success: true, kind, path: toRelPath(scopedRoot, target) });
|
|
129
135
|
} else if (actionType === 'delete') {
|
|
130
136
|
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
|
131
137
|
return json({ success: true });
|
|
132
138
|
} else if (actionType === 'rename') {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
// `newPath` is relative to the LISTED root, exactly like `path` is. It
|
|
140
|
+
// used to be resolved against `rootDir`, so renaming inside a nested repo
|
|
141
|
+
// resolved to a path under the workspace root instead of the repo — a
|
|
142
|
+
// move out of the tree the row belongs to.
|
|
143
|
+
const newPath = normalizeRelativePath((formData.get('newPath') as string) ?? '');
|
|
144
|
+
if (!newPath) {
|
|
145
|
+
return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
|
|
146
|
+
}
|
|
147
|
+
const fullNewPath = resolveWithinRoot(scopedRoot, newPath);
|
|
148
|
+
if (!fullNewPath) {
|
|
136
149
|
return json({ error: 'Invalid new path' }, { status: 403 });
|
|
137
150
|
}
|
|
151
|
+
if (fullNewPath === fullPath) return json({ success: true, path: newPath, previousPath: filePath });
|
|
152
|
+
const source = await Bun.file(fullPath).stat().catch(() => null);
|
|
153
|
+
if (!source) return json({ error: 'That entry no longer exists' }, { status: 404 });
|
|
154
|
+
// A folder cannot be moved into itself: `rename(a, a/b)` fails with
|
|
155
|
+
// EINVAL on every platform, but with an errno the panel cannot explain —
|
|
156
|
+
// and the destination directory may not exist yet, so the check has to be
|
|
157
|
+
// on the PATHS rather than on the filesystem.
|
|
158
|
+
if (source.isDirectory() && isWithinRoot(fullPath, fullNewPath)) {
|
|
159
|
+
return json({ error: 'A folder cannot be moved inside itself' }, { status: 400 });
|
|
160
|
+
}
|
|
161
|
+
// `rename` clobbers silently, so an existing destination is refused: the
|
|
162
|
+
// entry the user meant to keep would otherwise disappear without a word.
|
|
163
|
+
if ((await Bun.file(fullNewPath).stat().catch(() => null)) !== null) {
|
|
164
|
+
return json({ error: `${newPath} already exists` }, { status: 409 });
|
|
165
|
+
}
|
|
166
|
+
// The destination's parents are created, which is what makes a rename
|
|
167
|
+
// INTO a new folder work — the same nested grammar the create accepts.
|
|
168
|
+
await fs.promises.mkdir(path.dirname(fullNewPath), { recursive: true });
|
|
138
169
|
await fs.promises.rename(fullPath, fullNewPath);
|
|
139
|
-
return json({ success: true });
|
|
170
|
+
return json({ success: true, path: newPath, previousPath: filePath });
|
|
140
171
|
} else if (actionType === 'open_explorer') {
|
|
141
172
|
const isDir = (await Bun.file(fullPath).stat().catch(() => null))?.isDirectory() ?? false;
|
|
142
173
|
// A file reveals its containing directory — there is no portable "select
|