authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-22 01:08:10+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 18:08:10-04:00
log44e4a7071d84c95f4d273cc3c6c9682166502745
tree22c4b3413a02e28e8c0affa72ec015342aaf6244
parentb381fdb12cb7cb241e2b8aca84941375f3fb43f4
signature Signed by PGP key B5690EEEBB952194

feat(desktop): choose external project icons (#7823)


19 files changed, 485 insertions(+), 64 deletions(-)

apps/desktop/src/electron/ElectronDialog.test.ts+28
...@@ -53,6 +53,34 @@ describe("ElectronDialog", () => {...@@ -53,6 +53,34 @@ describe("ElectronDialog", () => {
53 }).pipe(Effect.provide(ElectronDialog.layer)),53 }).pipe(Effect.provide(ElectronDialog.layer)),
54 );54 );
5555
56 it.effect("opens a single-file picker when multiple selections are disabled", () =>
57 Effect.gen(function* () {
58 showOpenDialogMock.mockResolvedValue({
59 canceled: false,
60 filePaths: ["/pictures/icon.png"],
61 });
62 const dialog = yield* ElectronDialog.ElectronDialog;
63
64 const paths = yield* dialog.pickFiles({
65 owner: Option.none(),
66 defaultPath: Option.some("/project"),
67 filters: [{ name: "Images", extensions: ["png"] }],
68 multiple: false,
69 });
70
71 assert.deepEqual(paths, ["/pictures/icon.png"]);
72 assert.deepEqual(showOpenDialogMock.mock.calls, [
73 [
74 {
75 defaultPath: "/project",
76 filters: [{ name: "Images", extensions: ["png"] }],
77 properties: ["openFile"],
78 },
79 ],
80 ]);
81 }).pipe(Effect.provide(ElectronDialog.layer)),
82 );
83
56 it.effect("preserves message box request context and cause", () =>84 it.effect("preserves message box request context and cause", () =>
57 Effect.gen(function* () {85 Effect.gen(function* () {
58 const cause = new Error("message box failed");86 const cause = new Error("message box failed");
apps/desktop/src/electron/ElectronDialog.ts+2-1
...@@ -84,6 +84,7 @@ export interface ElectronDialogPickFilesInput {...@@ -84,6 +84,7 @@ export interface ElectronDialogPickFilesInput {
84 readonly owner: Option.Option<Electron.BrowserWindow>;84 readonly owner: Option.Option<Electron.BrowserWindow>;
85 readonly defaultPath: Option.Option<string>;85 readonly defaultPath: Option.Option<string>;
86 readonly filters: readonly Electron.FileFilter[];86 readonly filters: readonly Electron.FileFilter[];
87 readonly multiple: boolean;
87}88}
8889
89export class ElectronDialog extends Context.Service<90export class ElectronDialog extends Context.Service<
...@@ -144,7 +145,7 @@ export const make = ElectronDialog.of({...@@ -144,7 +145,7 @@ export const make = ElectronDialog.of({
144 });145 });
145 const defaultPath = Option.getOrNull(input.defaultPath);146 const defaultPath = Option.getOrNull(input.defaultPath);
146 const openDialogOptions: Electron.OpenDialogOptions = {147 const openDialogOptions: Electron.OpenDialogOptions = {
147 properties: ["openFile", "multiSelections"],148 properties: input.multiple ? ["openFile", "multiSelections"] : ["openFile"],
148 filters: [...input.filters],149 filters: [...input.filters],
149 ...(defaultPath === null ? {} : { defaultPath }),150 ...(defaultPath === null ? {} : { defaultPath }),
150 };151 };
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 openExternal,39 openExternal,
40 probeRemoteEditors,40 probeRemoteEditors,
41 pickFolder,41 pickFolder,
42 pickProjectFavicon,
42 pickThemeFiles,43 pickThemeFiles,
43 setTheme,44 setTheme,
44 showContextMenu,45 showContextMenu,
...@@ -82,6 +83,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -82,6 +83,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
82 yield* ipc.handle(setWslOnly);83 yield* ipc.handle(setWslOnly);
8384
84 yield* ipc.handle(pickFolder);85 yield* ipc.handle(pickFolder);
86 yield* ipc.handle(pickProjectFavicon);
85 yield* ipc.handle(pickThemeFiles);87 yield* ipc.handle(pickThemeFiles);
86 yield* ipc.handle(setTheme);88 yield* ipc.handle(setTheme);
87 yield* ipc.handle(showContextMenu);89 yield* ipc.handle(showContextMenu);
apps/desktop/src/ipc/channels.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
2export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";3export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
3export const SET_THEME_CHANNEL = "desktop:set-theme";4export const SET_THEME_CHANNEL = "desktop:set-theme";
4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";5export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
apps/desktop/src/ipc/methods/window.test.ts+42-1
...@@ -2,13 +2,19 @@ import { assert, describe, it } from "@effect/vitest";...@@ -2,13 +2,19 @@ import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { vi } from "vite-plus/test";
56
6import type * as Electron from "electron";7import type * as Electron from "electron";
78
8import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";9import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
9import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";10import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
11import * as ElectronDialog from "../../electron/ElectronDialog.ts";
10import * as ElectronWindow from "../../electron/ElectronWindow.ts";12import * as ElectronWindow from "../../electron/ElectronWindow.ts";
11import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts";13import {
14 getLocalEnvironmentBootstraps,
15 getWindowFullscreenState,
16 pickProjectFavicon,
17} from "./window.ts";
1218
13const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {19const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
14 executablePath: "wsl.exe",20 executablePath: "wsl.exe",
...@@ -146,3 +152,38 @@ describe("getWindowFullscreenState", () => {...@@ -146,3 +152,38 @@ describe("getWindowFullscreenState", () => {
146 );152 );
147 });153 });
148});154});
155
156describe("pickProjectFavicon", () => {
157 it.effect("opens a single-image picker from the project directory", () =>
158 Effect.gen(function* () {
159 const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"]));
160 const result = yield* pickProjectFavicon.handler("/project").pipe(
161 Effect.provide(
162 Layer.mergeAll(
163 Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }),
164 Layer.mock(ElectronWindow.ElectronWindow)({
165 focusedMainOrFirst: Effect.succeed(Option.none()),
166 }),
167 ),
168 ),
169 );
170
171 assert.strictEqual(result, "/pictures/icon.png");
172 assert.deepEqual(pickFiles.mock.calls, [
173 [
174 {
175 owner: Option.none(),
176 defaultPath: Option.some("/project"),
177 multiple: false,
178 filters: [
179 {
180 name: "Images",
181 extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"],
182 },
183 ],
184 },
185 ],
186 ]);
187 }),
188 );
189});
apps/desktop/src/ipc/methods/window.ts+24
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 type DesktopEnvironmentBootstrap,12 type DesktopEnvironmentBootstrap,
13 type PickedThemeFile,13 type PickedThemeFile,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";
15import { isCommandAvailable } from "@t3tools/shared/shell";16import { isCommandAvailable } from "@t3tools/shared/shell";
16import * as NodeOS from "node:os";17import * as NodeOS from "node:os";
17import * as FileSystem from "effect/FileSystem";18import * as FileSystem from "effect/FileSystem";
...@@ -234,6 +235,28 @@ export const pickFolder = DesktopIpc.makeIpcMethod({...@@ -234,6 +235,28 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
234 }),235 }),
235});236});
236237
238export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
239 channel: IpcChannels.PICK_PROJECT_FAVICON_CHANNEL,
240 payload: Schema.UndefinedOr(Schema.String),
241 result: Schema.NullOr(Schema.String),
242 handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) {
243 const dialog = yield* ElectronDialog.ElectronDialog;
244 const electronWindow = yield* ElectronWindow.ElectronWindow;
245 const paths = yield* dialog.pickFiles({
246 owner: yield* electronWindow.focusedMainOrFirst,
247 defaultPath: Option.fromNullishOr(initialPath),
248 multiple: false,
249 filters: [
250 {
251 name: "Images",
252 extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)),
253 },
254 ],
255 });
256 return paths[0] ?? null;
257 }),
258});
259
237export const setTheme = DesktopIpc.makeIpcMethod({260export const setTheme = DesktopIpc.makeIpcMethod({
238 channel: IpcChannels.SET_THEME_CHANNEL,261 channel: IpcChannels.SET_THEME_CHANNEL,
239 payload: DesktopThemeSchema,262 payload: DesktopThemeSchema,
...@@ -323,6 +346,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({...@@ -323,6 +346,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({
323 owner: yield* electronWindow.focusedMainOrFirst,346 owner: yield* electronWindow.focusedMainOrFirst,
324 defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(),347 defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(),
325 filters: [{ name: "JSON", extensions: ["json"] }],348 filters: [{ name: "JSON", extensions: ["json"] }],
349 multiple: true,
326 });350 });
327 if (paths.length === 0) {351 if (paths.length === 0) {
328 return null;352 return null;
apps/desktop/src/preload.ts+2
...@@ -101,6 +101,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -101,6 +101,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
101 setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro),101 setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro),
102 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),102 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
103 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),103 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
104 pickProjectFavicon: (initialPath) =>
105 ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath),
104 pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),106 pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
105 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),107 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
106 showContextMenu: (items, position) =>108 showContextMenu: (items, position) =>
apps/server/src/assets/AssetAccess.test.ts+38
...@@ -287,6 +287,44 @@ describe("AssetAccess", () => {...@@ -287,6 +287,44 @@ describe("AssetAccess", () => {
287 }).pipe(Effect.provide(testLayer)),287 }).pipe(Effect.provide(testLayer)),
288 );288 );
289289
290 it.effect("issues an exact capability for a saved favicon outside the workspace", () =>
291 Effect.gen(function* () {
292 const fileSystem = yield* FileSystem.FileSystem;
293 const path = yield* Path.Path;
294 const root = yield* fileSystem.makeTempDirectoryScoped({
295 prefix: "t3-asset-favicon-workspace-",
296 });
297 const pictures = yield* fileSystem.makeTempDirectoryScoped({
298 prefix: "t3-asset-favicon-pictures-",
299 });
300 const externalPath = path.join(pictures, "custom.png");
301 const siblingPath = path.join(pictures, "sibling.png");
302 yield* fileSystem.writeFile(externalPath, new Uint8Array([1, 2, 3]));
303 yield* fileSystem.writeFile(siblingPath, new Uint8Array([4, 5, 6]));
304 const canonicalPath = yield* fileSystem.realPath(externalPath);
305 const canonicalSiblingPath = yield* fileSystem.realPath(siblingPath);
306
307 const result = yield* issueAssetUrl({
308 resource: { _tag: "project-favicon", cwd: root },
309 projectFaviconPath: externalPath,
310 });
311 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
312 const separatorIndex = suffix.indexOf("/");
313
314 expect(result.sourcePath).toBe(externalPath);
315 expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.png$/);
316 expect(
317 yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
318 ).toEqual({ kind: "file", path: canonicalPath });
319 const tamperedSuffixResult = yield* resolveAsset(
320 suffix.slice(0, separatorIndex),
321 "sibling.png",
322 );
323 expect(tamperedSuffixResult).toEqual({ kind: "file", path: canonicalPath });
324 expect(tamperedSuffixResult).not.toEqual({ kind: "file", path: canonicalSiblingPath });
325 }).pipe(Effect.provide(testLayer)),
326 );
327
290 it.effect("ignores a client favicon path hint", () =>328 it.effect("ignores a client favicon path hint", () =>
291 Effect.gen(function* () {329 Effect.gen(function* () {
292 const fileSystem = yield* FileSystem.FileSystem;330 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+75-24
...@@ -88,6 +88,12 @@ const AssetClaimsSchema = Schema.Union([...@@ -88,6 +88,12 @@ const AssetClaimsSchema = Schema.Union([
88 relativePath: Schema.NullOr(Schema.String),88 relativePath: Schema.NullOr(Schema.String),
89 expiresAt: Schema.Number,89 expiresAt: Schema.Number,
90 }),90 }),
91 Schema.Struct({
92 version: Schema.Literal(1),
93 kind: Schema.Literal("project-favicon-external"),
94 filePath: Schema.String,
95 expiresAt: Schema.Number,
96 }),
91]);97]);
92type AssetClaims = typeof AssetClaimsSchema.Type;98type AssetClaims = typeof AssetClaimsSchema.Type;
9399
...@@ -124,6 +130,17 @@ const optionOnNotFound = <A, R>(...@@ -124,6 +130,17 @@ const optionOnNotFound = <A, R>(
124 }),130 }),
125 );131 );
126132
133const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
134 filePath: string,
135) {
136 const fileSystem = yield* FileSystem.FileSystem;
137 const canonicalFile = yield* optionOnNotFound(fileSystem.realPath(filePath));
138 if (Option.isNone(canonicalFile)) return null;
139
140 const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value));
141 return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null;
142});
143
127const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(144const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
128 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {145 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
129 const fileSystem = yield* FileSystem.FileSystem;146 const fileSystem = yield* FileSystem.FileSystem;
...@@ -300,13 +317,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -300,13 +317,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
300 }),317 }),
301 ),318 ),
302 );319 );
303 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;320 const isExternalOverride =
304 if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) {321 faviconPath !== null &&
322 input.projectFaviconPath !== undefined &&
323 path.isAbsolute(input.projectFaviconPath) &&
324 path.normalize(faviconPath) === path.normalize(input.projectFaviconPath);
325 const relativePath =
326 faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
327 const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
328 if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) {
305 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });329 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
306 }330 }
307 sourcePath = relativePath ?? undefined;331 sourcePath = sourceFaviconPath ?? undefined;
308 const canonicalFaviconPath = relativePath332 const canonicalFaviconPath = sourceFaviconPath
309 ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(333 ? yield* (
334 isExternalOverride
335 ? resolveCanonicalFile(sourceFaviconPath)
336 : resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: sourceFaviconPath })
337 ).pipe(
310 Effect.mapError(338 Effect.mapError(
311 (cause) =>339 (cause) =>
312 new AssetProjectFaviconInspectionError({340 new AssetProjectFaviconInspectionError({
...@@ -316,27 +344,35 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -316,27 +344,35 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
316 ),344 ),
317 )345 )
318 : null;346 : null;
319 if (relativePath && !canonicalFaviconPath) {347 if (sourceFaviconPath && !canonicalFaviconPath) {
320 return yield* new AssetProjectFaviconNotFoundError({348 return yield* new AssetProjectFaviconNotFoundError({
321 resource: input.resource,349 resource: input.resource,
322 });350 });
323 }351 }
324 claims = {352 claims =
325 version: 1,353 isExternalOverride && canonicalFaviconPath
326 kind: "project-favicon",354 ? {
327 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(355 version: 1,
328 Effect.mapError(356 kind: "project-favicon-external",
329 (cause) =>357 filePath: canonicalFaviconPath,
330 new AssetWorkspaceResolutionError({358 expiresAt,
331 resource: input.resource,359 }
332 cause,360 : {
333 }),361 version: 1,
334 ),362 kind: "project-favicon",
335 ),363 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
336 relativePath,364 Effect.mapError(
337 expiresAt,365 (cause) =>
338 };366 new AssetWorkspaceResolutionError({
339 if (relativePath && canonicalFaviconPath) {367 resource: input.resource,
368 cause,
369 }),
370 ),
371 ),
372 relativePath,
373 expiresAt,
374 };
375 if (sourceFaviconPath && canonicalFaviconPath) {
340 const crypto = yield* Crypto.Crypto;376 const crypto = yield* Crypto.Crypto;
341 const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe(377 const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe(
342 Effect.mapError(378 Effect.mapError(
...@@ -357,7 +393,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -357,7 +393,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
357 }),393 }),
358 ),394 ),
359 );395 );
360 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`;396 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(sourceFaviconPath)}`;
361 } else {397 } else {
362 fileName = PROJECT_FAVICON_FALLBACK_MARKER;398 fileName = PROJECT_FAVICON_FALLBACK_MARKER;
363 }399 }
...@@ -375,7 +411,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -375,7 +411,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
375 }),411 }),
376 ),412 ),
377 );413 );
378 if (claims.kind === "project-favicon") {414 if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") {
379 const issuedAt = yield* Clock.currentTimeMillis;415 const issuedAt = yield* Clock.currentTimeMillis;
380 expiresAt =416 expiresAt =
381 (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) *417 (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) *
...@@ -441,6 +477,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -441,6 +477,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
441 return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;477 return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
442 }478 }
443479
480 if (claims.kind === "project-favicon-external") {
481 const faviconPath = yield* resolveCanonicalFile(claims.filePath).pipe(
482 Effect.tapError((cause) =>
483 Effect.logError("Failed to resolve canonical asset path.", {
484 filePath: claims.filePath,
485 cause,
486 }),
487 ),
488 Effect.orElseSucceed(() => null),
489 );
490 return faviconPath === claims.filePath
491 ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset)
492 : null;
493 }
494
444 const decodedPath = decodeRelativePath(relativePath);495 const decodedPath = decodeRelativePath(relativePath);
445 if (decodedPath === null) return null;496 if (decodedPath === null) return null;
446 const path = yield* Path.Path;497 const path = yield* Path.Path;
apps/server/src/project/ProjectFaviconResolver.test.ts+15
...@@ -91,6 +91,21 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -91,6 +91,21 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
91 }),91 }),
92 );92 );
9393
94 it.effect("uses a saved project favicon outside the workspace", () =>
95 Effect.gen(function* () {
96 const path = yield* Path.Path;
97 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
98 const cwd = yield* makeTempDir;
99 const pictures = yield* makeTempDir;
100 yield* writeTextFile(pictures, "custom.png", "image");
101 const externalPath = path.join(pictures, "custom.png");
102
103 const resolved = yield* resolver.resolvePath(cwd, externalPath);
104
105 expect(resolved).toBe(externalPath);
106 }),
107 );
108
94 it.effect("falls back when a saved override is missing from a checkout", () =>109 it.effect("falls back when a saved override is missing from a checkout", () =>
95 Effect.gen(function* () {110 Effect.gen(function* () {
96 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;111 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+25-18
...@@ -137,22 +137,25 @@ export const make = Effect.gen(function* () {...@@ -137,22 +137,25 @@ export const make = Effect.gen(function* () {
137 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (137 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
138 projectCwd: string,138 projectCwd: string,
139 relativeCandidates: ReadonlyArray<string>,139 relativeCandidates: ReadonlyArray<string>,
140 candidateScope: "workspace" | "filesystem",
140 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {141 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
141 for (const relativePath of relativeCandidates) {142 for (const relativePath of relativeCandidates) {
142 const candidate = yield* workspacePaths143 const candidate = yield* (
143 .resolveRelativePathWithinRoot({144 candidateScope === "filesystem" && path.isAbsolute(relativePath)
144 workspaceRoot: projectCwd,145 ? Effect.succeed({ absolutePath: relativePath, relativePath })
145 relativePath,146 : workspacePaths.resolveRelativePathWithinRoot({
146 })147 workspaceRoot: projectCwd,
147 .pipe(148 relativePath,
148 Effect.map(Option.some),149 })
149 Effect.catchTags({150 ).pipe(
150 WorkspacePathOutsideRootError: () =>151 Effect.map(Option.some),
151 Effect.succeed(152 Effect.catchTags({
152 Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(),153 WorkspacePathOutsideRootError: () =>
153 ),154 Effect.succeed(
154 }),155 Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(),
155 );156 ),
157 }),
158 );
156 if (Option.isNone(candidate)) {159 if (Option.isNone(candidate)) {
157 continue;160 continue;
158 }161 }
...@@ -191,7 +194,7 @@ export const make = Effect.gen(function* () {...@@ -191,7 +194,7 @@ export const make = Effect.gen(function* () {
191 // A grouped project's saved path can be absent from one checkout. Use it194 // A grouped project's saved path can be absent from one checkout. Use it
192 // where it exists and retain automatic discovery for the other checkouts.195 // where it exists and retain automatic discovery for the other checkouts.
193 if (faviconPath !== undefined) {196 if (faviconPath !== undefined) {
194 const existing = yield* findExistingFile(projectCwd, [faviconPath]);197 const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem");
195 if (existing) {198 if (existing) {
196 return existing;199 return existing;
197 }200 }
...@@ -200,14 +203,18 @@ export const make = Effect.gen(function* () {...@@ -200,14 +203,18 @@ export const make = Effect.gen(function* () {
200 // A t3.json iconPath takes precedence over the well-known locations.203 // A t3.json iconPath takes precedence over the well-known locations.
201 const projectFile = yield* projectFileLoader.load(projectCwd);204 const projectFile = yield* projectFileLoader.load(projectCwd);
202 if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {205 if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
203 const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]);206 const existing = yield* findExistingFile(
207 projectCwd,
208 [projectFile.value.iconPath],
209 "workspace",
210 );
204 if (existing) {211 if (existing) {
205 return existing;212 return existing;
206 }213 }
207 }214 }
208215
209 for (const candidate of FAVICON_CANDIDATES) {216 for (const candidate of FAVICON_CANDIDATES) {
210 const existing = yield* findExistingFile(projectCwd, [candidate]);217 const existing = yield* findExistingFile(projectCwd, [candidate], "workspace");
211 if (existing) {218 if (existing) {
212 return existing;219 return existing;
213 }220 }
...@@ -251,7 +258,7 @@ export const make = Effect.gen(function* () {...@@ -251,7 +258,7 @@ export const make = Effect.gen(function* () {
251 if (!href) {258 if (!href) {
252 continue;259 continue;
253 }260 }
254 const existing = yield* findExistingFile(projectCwd, resolveIconHref(href));261 const existing = yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace");
255 if (existing) {262 if (existing) {
256 return existing;263 return existing;
257 }264 }
apps/web/src/components/CommandPalette.tsx+10-17
...@@ -95,7 +95,13 @@ import { isPreviewFocused } from "../lib/previewFocus";...@@ -95,7 +95,13 @@ import { isPreviewFocused } from "../lib/previewFocus";
95import { isTerminalFocused } from "../lib/terminalFocus";95import { isTerminalFocused } from "../lib/terminalFocus";
96import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";96import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
97import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";97import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
98import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";98import {
99 cn,
100 getLocalFileManagerName,
101 isMacPlatform,
102 isWindowsPlatform,
103 newProjectId,
104} from "../lib/utils";
99import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";105import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
100import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";106import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
101import {107import {
...@@ -147,7 +153,7 @@ import {...@@ -147,7 +153,7 @@ import {
147 type ProviderInstanceEntry,153 type ProviderInstanceEntry,
148} from "../providerInstances";154} from "../providerInstances";
149import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";155import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
150import { CommandDialog, CommandDialogPopup } from "./ui/command";156import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
151import { Button } from "./ui/button";157import { Button } from "./ui/button";
152import { Kbd, KbdGroup } from "./ui/kbd";158import { Kbd, KbdGroup } from "./ui/kbd";
153import { stackedThreadToast, toastManager } from "./ui/toast";159import { stackedThreadToast, toastManager } from "./ui/toast";
...@@ -175,16 +181,6 @@ function projectFavicon(project: Project) {...@@ -175,16 +181,6 @@ function projectFavicon(project: Project) {
175 );181 );
176}182}
177183
178function getLocalFileManagerName(platform: string): string {
179 if (isMacPlatform(platform)) {
180 return "Finder";
181 }
182 if (isWindowsPlatform(platform)) {
183 return "Explorer";
184 }
185 return "Files";
186}
187
188function getEnvironmentBrowsePlatform(os: string | null | undefined): string {184function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
189 if (os === "windows") {185 if (os === "windows") {
190 return "Win32";186 return "Win32";
...@@ -2418,17 +2414,14 @@ function OpenCommandPaletteDialog(props: {...@@ -2418,17 +2414,14 @@ function OpenCommandPaletteDialog(props: {
2418 : undefined;2414 : undefined;
24192415
2420 const footerTrailing = canOpenProjectFromFileManager ? (2416 const footerTrailing = canOpenProjectFromFileManager ? (
2421 <Button2417 <CommandFooterAction
2422 variant="ghost"
2423 size="xs"
2424 className="h-auto px-2 text-muted-foreground text-xs hover:bg-transparent hover:text-foreground"
2425 disabled={isPickingProjectFolder}2418 disabled={isPickingProjectFolder}
2426 onClick={() => {2419 onClick={() => {
2427 void handleOpenProjectFromFileManager();2420 void handleOpenProjectFromFileManager();
2428 }}2421 }}
2429 >2422 >
2430 {`Open in ${fileManagerName}`}2423 {`Open in ${fileManagerName}`}
2431 </Button>2424 </CommandFooterAction>
2432 ) : null;2425 ) : null;
24332426
2434 return (2427 return (
apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx created+125
...@@ -0,0 +1,125 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type { ReactElement } from "react";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { visitElements } from "../../test/reactElementTree";
6import { reactHookHarness as hooks } from "../../test/reactHookHarness";
7
8vi.mock("react", async (importOriginal) => {
9 const actual = await importOriginal<typeof import("react")>();
10 const { reactHookHarness } = await import("../../test/reactHookHarness");
11 return {
12 ...actual,
13 useMemo: reactHookHarness.useMemo,
14 useState: reactHookHarness.useState,
15 };
16});
17
18vi.mock("react/compiler-runtime", async () => {
19 const { reactHookHarness } = await import("../../test/reactHookHarness");
20 return { c: reactHookHarness.useMemoCache };
21});
22
23vi.mock("@effect/atom-react", () => ({
24 useAtomValue: () => ({}),
25}));
26
27vi.mock("~/state/server", () => ({
28 primaryServerKeybindingsAtom: Symbol("keybindings"),
29}));
30
31vi.mock("~/hooks/useTheme", () => ({
32 useTheme: () => ({ resolvedTheme: "dark" }),
33}));
34
35vi.mock("../files/projectFilesQueryState", () => ({
36 useProjectFilePickerQuery: () => ({
37 entries: [],
38 error: null,
39 isPending: false,
40 matchedQuery: "",
41 }),
42}));
43
44vi.mock("../ui/toast", () => ({
45 toastManager: { add: vi.fn() },
46}));
47
48import { toastManager } from "../ui/toast";
49import {
50 canPickExternalProjectFavicon,
51 ProjectFaviconPickerDialog,
52} from "./ProjectFaviconPickerDialog";
53
54describe("ProjectFaviconPickerDialog", () => {
55 beforeEach(() => {
56 hooks.reset();
57 vi.stubGlobal("navigator", { platform: "MacIntel" });
58 });
59
60 it("selects an image from the native file picker", async () => {
61 const onOpenChange = vi.fn();
62 const onPickExternal = vi.fn().mockResolvedValue("/Users/me/Pictures/icon.png");
63 const onSelect = vi.fn();
64 hooks.beginRender();
65 const picker = ProjectFaviconPickerDialog({
66 cwd: "/Users/me/project",
67 environmentId: EnvironmentId.make("local"),
68 onOpenChange,
69 onPickExternal,
70 onSelect,
71 open: true,
72 projectName: "Project",
73 } as Parameters<typeof ProjectFaviconPickerDialog>[0] & {
74 readonly onPickExternal: () => Promise<string | null>;
75 }) as ReactElement<Record<string, unknown>>;
76
77 const button = visitElements(picker, (element) => element.props.children === "Open in Finder");
78 expect(button).not.toBeNull();
79
80 (button?.props.onClick as (() => void) | undefined)?.();
81 await Promise.resolve();
82 await Promise.resolve();
83
84 expect(onPickExternal).toHaveBeenCalledTimes(1);
85 expect(onOpenChange).toHaveBeenCalledWith(false);
86 expect(onSelect).toHaveBeenCalledWith("/Users/me/Pictures/icon.png");
87 });
88
89 it("hides the native picker for WSL project paths", () => {
90 expect(canPickExternalProjectFavicon("/home/me/project", "Win32")).toBe(false);
91 expect(canPickExternalProjectFavicon("C:\\Users\\me\\project", "Win32")).toBe(true);
92 });
93
94 it("keeps the dialog open when the native picker fails", async () => {
95 const onOpenChange = vi.fn();
96 const onSelect = vi.fn();
97 const props = {
98 cwd: "/Users/me/project",
99 environmentId: EnvironmentId.make("local"),
100 onOpenChange,
101 onPickExternal: vi.fn().mockRejectedValue(new Error("picker failed")),
102 onSelect,
103 open: true,
104 projectName: "Project",
105 } as Parameters<typeof ProjectFaviconPickerDialog>[0] & {
106 readonly onPickExternal: () => Promise<string | null>;
107 };
108
109 hooks.beginRender();
110 const picker = ProjectFaviconPickerDialog(props) as ReactElement<Record<string, unknown>>;
111 const button = visitElements(picker, (element) => element.props.children === "Open in Finder");
112
113 (button?.props.onClick as (() => void) | undefined)?.();
114 await Promise.resolve();
115 await Promise.resolve();
116
117 expect(onOpenChange).not.toHaveBeenCalled();
118 expect(onSelect).not.toHaveBeenCalled();
119 expect(toastManager.add).toHaveBeenCalledWith({
120 type: "error",
121 title: "Could not open image picker",
122 description: "picker failed",
123 });
124 });
125});
apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx+40-1
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId } from "@t3tools/contracts";
3import { isWindowsAbsolutePath } from "@t3tools/shared/path";
3import { useMemo, useState } from "react";4import { useMemo, useState } from "react";
45
5import { primaryServerKeybindingsAtom } from "~/state/server";6import { primaryServerKeybindingsAtom } from "~/state/server";
6import { useTheme } from "~/hooks/useTheme";7import { useTheme } from "~/hooks/useTheme";
8import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils";
7import { CommandPaletteContent } from "../CommandPaletteContent";9import { CommandPaletteContent } from "../CommandPaletteContent";
8import type { CommandPaletteActionItem } from "../CommandPalette.logic";10import type { CommandPaletteActionItem } from "../CommandPalette.logic";
9import { CommandPaletteResults } from "../CommandPaletteResults";11import { CommandPaletteResults } from "../CommandPaletteResults";
...@@ -13,24 +15,30 @@ import {...@@ -13,24 +15,30 @@ import {
13 PROJECT_FILE_PICKER_RESULT_LIMIT,15 PROJECT_FILE_PICKER_RESULT_LIMIT,
14} from "../files/ProjectFilePicker.logic";16} from "../files/ProjectFilePicker.logic";
15import { useProjectFilePickerQuery } from "../files/projectFilesQueryState";17import { useProjectFilePickerQuery } from "../files/projectFilesQueryState";
16import { CommandDialog, CommandDialogPopup } from "../ui/command";18import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "../ui/command";
19import { toastManager } from "../ui/toast";
1720
18function emptyMessage(query: string, error: string | null, isPending: boolean): string {21function emptyMessage(query: string, error: string | null, isPending: boolean): string {
19 if (error) return error;22 if (error) return error;
20 if (isPending) return query.trim() ? "Searching project files…" : "Indexing project files…";23 if (isPending) return query.trim() ? "Searching project files…" : "Indexing project files…";
21 return query.trim() ? "No matching image files." : "No image files found.";24 return query.trim() ? "No matching image files." : "No image files found.";
22}25}
26export function canPickExternalProjectFavicon(cwd: string, platform: string): boolean {
27 return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd);
28}
2329
24export function ProjectFaviconPickerDialog(props: {30export function ProjectFaviconPickerDialog(props: {
25 readonly cwd: string;31 readonly cwd: string;
26 readonly environmentId: EnvironmentId;32 readonly environmentId: EnvironmentId;
27 readonly onOpenChange: (open: boolean) => void;33 readonly onOpenChange: (open: boolean) => void;
34 readonly onPickExternal?: () => Promise<string | null>;
28 readonly onSelect: (path: string) => void;35 readonly onSelect: (path: string) => void;
29 readonly open: boolean;36 readonly open: boolean;
30 readonly projectName: string;37 readonly projectName: string;
31}) {38}) {
32 const [query, setQuery] = useState("");39 const [query, setQuery] = useState("");
33 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);40 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
41 const [isPickingExternal, setIsPickingExternal] = useState(false);
34 const result = useProjectFilePickerQuery(42 const result = useProjectFilePickerQuery(
35 props.environmentId,43 props.environmentId,
36 props.cwd,44 props.cwd,
...@@ -40,6 +48,10 @@ export function ProjectFaviconPickerDialog(props: {...@@ -40,6 +48,10 @@ export function ProjectFaviconPickerDialog(props: {
40 );48 );
41 const { resolvedTheme } = useTheme();49 const { resolvedTheme } = useTheme();
42 const keybindings = useAtomValue(primaryServerKeybindingsAtom);50 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
51 const pickExternal = props.onPickExternal;
52 const fileManagerName = getLocalFileManagerName(
53 typeof navigator === "undefined" ? "" : navigator.platform,
54 );
43 const items = useMemo<CommandPaletteActionItem[]>(55 const items = useMemo<CommandPaletteActionItem[]>(
44 () =>56 () =>
45 getProjectFilePickerMatches(result.entries, result.matchedQuery).map((match) => ({57 getProjectFilePickerMatches(result.entries, result.matchedQuery).map((match) => ({
...@@ -67,6 +79,33 @@ export function ProjectFaviconPickerDialog(props: {...@@ -67,6 +79,33 @@ export function ProjectFaviconPickerDialog(props: {
67 autoHighlight="always"79 autoHighlight="always"
68 escapeLabel="Close"80 escapeLabel="Close"
69 footerActionLabel="Select icon"81 footerActionLabel="Select icon"
82 footerTrailing={
83 pickExternal ? (
84 <CommandFooterAction
85 disabled={isPickingExternal}
86 onClick={() => {
87 setIsPickingExternal(true);
88 void pickExternal()
89 .then((path) => {
90 if (!path) return;
91 props.onOpenChange(false);
92 props.onSelect(path);
93 })
94 .catch((error: unknown) => {
95 toastManager.add({
96 type: "error",
97 title: "Could not open image picker",
98 description:
99 error instanceof Error ? error.message : "An error occurred.",
100 });
101 })
102 .finally(() => setIsPickingExternal(false));
103 }}
104 >
105 {`Open in ${fileManagerName}`}
106 </CommandFooterAction>
107 ) : null
108 }
70 inputProps={{ placeholder: "Search image files…" }}109 inputProps={{ placeholder: "Search image files…" }}
71 mode="none"110 mode="none"
72 onItemHighlighted={(value) => {111 onItemHighlighted={(value) => {
apps/web/src/components/settings/ProjectSettingsPanel.tsx+17-1
...@@ -108,7 +108,10 @@ import {...@@ -108,7 +108,10 @@ import {
108 SettingsRow,108 SettingsRow,
109 SettingsSection,109 SettingsSection,
110} from "./settingsLayout";110} from "./settingsLayout";
111import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog";111import {
112 canPickExternalProjectFavicon,
113 ProjectFaviconPickerDialog,
114} from "./ProjectFaviconPickerDialog";
112115
113export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {116export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
114 repository: "Group by repository",117 repository: "Group by repository",
...@@ -286,6 +289,7 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {...@@ -286,6 +289,7 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
286289
287function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {290function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
288 const navigate = useNavigate();291 const navigate = useNavigate();
292 const primaryEnvironmentId = usePrimaryEnvironmentId();
289 const settings = usePrimarySettings();293 const settings = usePrimarySettings();
290 const updateClientSettings = useUpdateClientSettings();294 const updateClientSettings = useUpdateClientSettings();
291 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);295 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
...@@ -319,6 +323,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -319,6 +323,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
319 (member) => member.environmentId === group.environmentId && member.id === group.id,323 (member) => member.environmentId === group.environmentId && member.id === group.id,
320 ) ?? group.memberProjects[0]!;324 ) ?? group.memberProjects[0]!;
321 const faviconPath = representative.faviconPath ?? null;325 const faviconPath = representative.faviconPath ?? null;
326 const pickProjectFavicon =
327 typeof window !== "undefined" &&
328 group.memberProjects.every(
329 (member) =>
330 member.environmentId === primaryEnvironmentId &&
331 canPickExternalProjectFavicon(member.workspaceRoot, navigator.platform),
332 )
333 ? window.desktopBridge?.pickProjectFavicon
334 : undefined;
322335
323 const threadCountByMember = useMemo(() => {336 const threadCountByMember = useMemo(() => {
324 const counts = new Map<string, number>();337 const counts = new Map<string, number>();
...@@ -1166,6 +1179,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -1166,6 +1179,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
1166 cwd={representative.workspaceRoot}1179 cwd={representative.workspaceRoot}
1167 environmentId={representative.environmentId}1180 environmentId={representative.environmentId}
1168 onOpenChange={setFaviconPickerOpen}1181 onOpenChange={setFaviconPickerOpen}
1182 {...(pickProjectFavicon
1183 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
1184 : {})}
1169 onSelect={(path) => void setFaviconPath(path)}1185 onSelect={(path) => void setFaviconPath(path)}
1170 open={faviconPickerOpen}1186 open={faviconPickerOpen}
1171 projectName={group.displayName}1187 projectName={group.displayName}
apps/web/src/components/ui/command.tsx+16
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 AutocompleteSeparator,16 AutocompleteSeparator,
17} from "~/components/ui/autocomplete";17} from "~/components/ui/autocomplete";
18import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";18import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
19import { Button } from "~/components/ui/button";
1920
20const CommandDialog = CommandDialogPrimitive.Root;21const CommandDialog = CommandDialogPrimitive.Root;
2122
...@@ -225,6 +226,20 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {...@@ -225,6 +226,20 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {
225 );226 );
226}227}
227228
229function CommandFooterAction({
230 className,
231 ...props
232}: Omit<React.ComponentProps<typeof Button>, "size" | "variant">) {
233 return (
234 <Button
235 {...props}
236 variant="ghost-muted"
237 size="xs"
238 className={cn("h-auto px-2 text-xs hover:bg-transparent", className)}
239 />
240 );
241}
242
228export {243export {
229 CommandCreateHandle,244 CommandCreateHandle,
230 Command,245 Command,
...@@ -234,6 +249,7 @@ export {...@@ -234,6 +249,7 @@ export {
234 CommandDialogTrigger,249 CommandDialogTrigger,
235 CommandEmpty,250 CommandEmpty,
236 CommandFooter,251 CommandFooter,
252 CommandFooterAction,
237 CommandGroup,253 CommandGroup,
238 CommandGroupLabel,254 CommandGroupLabel,
239 CommandInput,255 CommandInput,
apps/web/src/lib/utils.test.ts+11-1
...@@ -1,5 +1,15 @@...@@ -1,5 +1,15 @@
1import { describe, assert, it } from "vite-plus/test";1import { describe, assert, it } from "vite-plus/test";
2import { isWindowsPlatform } from "./utils";2import { getLocalFileManagerName, isWindowsPlatform } from "./utils";
3
4describe("getLocalFileManagerName", () => {
5 it.each([
6 ["MacIntel", "Finder"],
7 ["Win32", "Explorer"],
8 ["Linux", "Files"],
9 ])("uses the %s file manager name", (platform, expected) => {
10 assert.strictEqual(getLocalFileManagerName(platform), expected);
11 });
12});
313
4describe("isWindowsPlatform", () => {14describe("isWindowsPlatform", () => {
5 it("matches Windows platform identifiers", () => {15 it("matches Windows platform identifiers", () => {
apps/web/src/lib/utils.ts+10
...@@ -20,6 +20,16 @@ export function isLinuxPlatform(platform: string): boolean {...@@ -20,6 +20,16 @@ export function isLinuxPlatform(platform: string): boolean {
20 return /linux/i.test(platform);20 return /linux/i.test(platform);
21}21}
2222
23export function getLocalFileManagerName(platform: string): string {
24 if (isMacPlatform(platform)) {
25 return "Finder";
26 }
27 if (isWindowsPlatform(platform)) {
28 return "Explorer";
29 }
30 return "Files";
31}
32
23export function randomHex(byteLength: number): string {33export function randomHex(byteLength: number): string {
24 return Encoding.encodeHex(globalThis.crypto.getRandomValues(new Uint8Array(byteLength)));34 return Encoding.encodeHex(globalThis.crypto.getRandomValues(new Uint8Array(byteLength)));
25}35}
packages/contracts/src/ipc.ts+2
...@@ -1110,6 +1110,8 @@ export interface DesktopBridge {...@@ -1110,6 +1110,8 @@ export interface DesktopBridge {
1110 setWslDistro: (distro: string | null) => Promise<DesktopWslState>;1110 setWslDistro: (distro: string | null) => Promise<DesktopWslState>;
1111 setWslOnly: (enabled: boolean) => Promise<DesktopWslState>;1111 setWslOnly: (enabled: boolean) => Promise<DesktopWslState>;
1112 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;1112 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
1113 /** Optional while older desktop shells can host a newer web client. */
1114 pickProjectFavicon?: (initialPath?: string) => Promise<string | null>;
1113 /**1115 /**
1114 * Multi-select JSON file picker that opens in the VS Code extensions1116 * Multi-select JSON file picker that opens in the VS Code extensions
1115 * directory when one exists. Optional: older desktop builds lack it, and1117 * directory when one exists. Optional: older desktop builds lack it, and