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", () => {
5353 }).pipe(Effect.provide(ElectronDialog.layer)),
5454 );
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
5684 it.effect("preserves message box request context and cause", () =>
5785 Effect.gen(function* () {
5886 const cause = new Error("message box failed");
apps/desktop/src/electron/ElectronDialog.ts+2-1
......@@ -84,6 +84,7 @@ export interface ElectronDialogPickFilesInput {
8484 readonly owner: Option.Option<Electron.BrowserWindow>;
8585 readonly defaultPath: Option.Option<string>;
8686 readonly filters: readonly Electron.FileFilter[];
87 readonly multiple: boolean;
8788}
8889
8990export class ElectronDialog extends Context.Service<
......@@ -144,7 +145,7 @@ export const make = ElectronDialog.of({
144145 });
145146 const defaultPath = Option.getOrNull(input.defaultPath);
146147 const openDialogOptions: Electron.OpenDialogOptions = {
147 properties: ["openFile", "multiSelections"],
148 properties: input.multiple ? ["openFile", "multiSelections"] : ["openFile"],
148149 filters: [...input.filters],
149150 ...(defaultPath === null ? {} : { defaultPath }),
150151 };
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -39,6 +39,7 @@ import {
3939 openExternal,
4040 probeRemoteEditors,
4141 pickFolder,
42 pickProjectFavicon,
4243 pickThemeFiles,
4344 setTheme,
4445 showContextMenu,
......@@ -82,6 +83,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
8283 yield* ipc.handle(setWslOnly);
8384
8485 yield* ipc.handle(pickFolder);
86 yield* ipc.handle(pickProjectFavicon);
8587 yield* ipc.handle(pickThemeFiles);
8688 yield* ipc.handle(setTheme);
8789 yield* ipc.handle(showContextMenu);
apps/desktop/src/ipc/channels.ts+1
......@@ -1,4 +1,5 @@
11export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
23export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
34export const SET_THEME_CHANNEL = "desktop:set-theme";
45export 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";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
5import { vi } from "vite-plus/test";
56
67import type * as Electron from "electron";
78
89import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
910import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
11import * as ElectronDialog from "../../electron/ElectronDialog.ts";
1012import * as ElectronWindow from "../../electron/ElectronWindow.ts";
11import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts";
13import {
14 getLocalEnvironmentBootstraps,
15 getWindowFullscreenState,
16 pickProjectFavicon,
17} from "./window.ts";
1218
1319const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
1420 executablePath: "wsl.exe",
......@@ -146,3 +152,38 @@ describe("getWindowFullscreenState", () => {
146152 );
147153 });
148154});
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 {
1212 type DesktopEnvironmentBootstrap,
1313 type PickedThemeFile,
1414} from "@t3tools/contracts";
15import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";
1516import { isCommandAvailable } from "@t3tools/shared/shell";
1617import * as NodeOS from "node:os";
1718import * as FileSystem from "effect/FileSystem";
......@@ -234,6 +235,28 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
234235 }),
235236});
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
237260export const setTheme = DesktopIpc.makeIpcMethod({
238261 channel: IpcChannels.SET_THEME_CHANNEL,
239262 payload: DesktopThemeSchema,
......@@ -323,6 +346,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({
323346 owner: yield* electronWindow.focusedMainOrFirst,
324347 defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(),
325348 filters: [{ name: "JSON", extensions: ["json"] }],
349 multiple: true,
326350 });
327351 if (paths.length === 0) {
328352 return null;
apps/desktop/src/preload.ts+2
......@@ -101,6 +101,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
101101 setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro),
102102 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
103103 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
104 pickProjectFavicon: (initialPath) =>
105 ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath),
104106 pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
105107 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
106108 showContextMenu: (items, position) =>
apps/server/src/assets/AssetAccess.test.ts+38
......@@ -287,6 +287,44 @@ describe("AssetAccess", () => {
287287 }).pipe(Effect.provide(testLayer)),
288288 );
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
290328 it.effect("ignores a client favicon path hint", () =>
291329 Effect.gen(function* () {
292330 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+75-24
......@@ -88,6 +88,12 @@ const AssetClaimsSchema = Schema.Union([
8888 relativePath: Schema.NullOr(Schema.String),
8989 expiresAt: Schema.Number,
9090 }),
91 Schema.Struct({
92 version: Schema.Literal(1),
93 kind: Schema.Literal("project-favicon-external"),
94 filePath: Schema.String,
95 expiresAt: Schema.Number,
96 }),
9197]);
9298type AssetClaims = typeof AssetClaimsSchema.Type;
9399
......@@ -124,6 +130,17 @@ const optionOnNotFound = <A, R>(
124130 }),
125131 );
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
127144const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
128145 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
129146 const fileSystem = yield* FileSystem.FileSystem;
......@@ -300,13 +317,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
300317 }),
301318 ),
302319 );
303 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
304 if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) {
320 const isExternalOverride =
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)) {
305329 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
306330 }
307 sourcePath = relativePath ?? undefined;
308 const canonicalFaviconPath = relativePath
309 ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
331 sourcePath = sourceFaviconPath ?? undefined;
332 const canonicalFaviconPath = sourceFaviconPath
333 ? yield* (
334 isExternalOverride
335 ? resolveCanonicalFile(sourceFaviconPath)
336 : resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: sourceFaviconPath })
337 ).pipe(
310338 Effect.mapError(
311339 (cause) =>
312340 new AssetProjectFaviconInspectionError({
......@@ -316,27 +344,35 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
316344 ),
317345 )
318346 : null;
319 if (relativePath && !canonicalFaviconPath) {
347 if (sourceFaviconPath && !canonicalFaviconPath) {
320348 return yield* new AssetProjectFaviconNotFoundError({
321349 resource: input.resource,
322350 });
323351 }
324 claims = {
325 version: 1,
326 kind: "project-favicon",
327 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
328 Effect.mapError(
329 (cause) =>
330 new AssetWorkspaceResolutionError({
331 resource: input.resource,
332 cause,
333 }),
334 ),
335 ),
336 relativePath,
337 expiresAt,
338 };
339 if (relativePath && canonicalFaviconPath) {
352 claims =
353 isExternalOverride && canonicalFaviconPath
354 ? {
355 version: 1,
356 kind: "project-favicon-external",
357 filePath: canonicalFaviconPath,
358 expiresAt,
359 }
360 : {
361 version: 1,
362 kind: "project-favicon",
363 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
364 Effect.mapError(
365 (cause) =>
366 new AssetWorkspaceResolutionError({
367 resource: input.resource,
368 cause,
369 }),
370 ),
371 ),
372 relativePath,
373 expiresAt,
374 };
375 if (sourceFaviconPath && canonicalFaviconPath) {
340376 const crypto = yield* Crypto.Crypto;
341377 const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe(
342378 Effect.mapError(
......@@ -357,7 +393,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
357393 }),
358394 ),
359395 );
360 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`;
396 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(sourceFaviconPath)}`;
361397 } else {
362398 fileName = PROJECT_FAVICON_FALLBACK_MARKER;
363399 }
......@@ -375,7 +411,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
375411 }),
376412 ),
377413 );
378 if (claims.kind === "project-favicon") {
414 if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") {
379415 const issuedAt = yield* Clock.currentTimeMillis;
380416 expiresAt =
381417 (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) *
......@@ -441,6 +477,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
441477 return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
442478 }
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
444495 const decodedPath = decodeRelativePath(relativePath);
445496 if (decodedPath === null) return null;
446497 const path = yield* Path.Path;
apps/server/src/project/ProjectFaviconResolver.test.ts+15
......@@ -91,6 +91,21 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
9191 }),
9292 );
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
94109 it.effect("falls back when a saved override is missing from a checkout", () =>
95110 Effect.gen(function* () {
96111 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+25-18
......@@ -137,22 +137,25 @@ export const make = Effect.gen(function* () {
137137 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
138138 projectCwd: string,
139139 relativeCandidates: ReadonlyArray<string>,
140 candidateScope: "workspace" | "filesystem",
140141 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
141142 for (const relativePath of relativeCandidates) {
142 const candidate = yield* workspacePaths
143 .resolveRelativePathWithinRoot({
144 workspaceRoot: projectCwd,
145 relativePath,
146 })
147 .pipe(
148 Effect.map(Option.some),
149 Effect.catchTags({
150 WorkspacePathOutsideRootError: () =>
151 Effect.succeed(
152 Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(),
153 ),
154 }),
155 );
143 const candidate = yield* (
144 candidateScope === "filesystem" && path.isAbsolute(relativePath)
145 ? Effect.succeed({ absolutePath: relativePath, relativePath })
146 : workspacePaths.resolveRelativePathWithinRoot({
147 workspaceRoot: projectCwd,
148 relativePath,
149 })
150 ).pipe(
151 Effect.map(Option.some),
152 Effect.catchTags({
153 WorkspacePathOutsideRootError: () =>
154 Effect.succeed(
155 Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(),
156 ),
157 }),
158 );
156159 if (Option.isNone(candidate)) {
157160 continue;
158161 }
......@@ -191,7 +194,7 @@ export const make = Effect.gen(function* () {
191194 // A grouped project's saved path can be absent from one checkout. Use it
192195 // where it exists and retain automatic discovery for the other checkouts.
193196 if (faviconPath !== undefined) {
194 const existing = yield* findExistingFile(projectCwd, [faviconPath]);
197 const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem");
195198 if (existing) {
196199 return existing;
197200 }
......@@ -200,14 +203,18 @@ export const make = Effect.gen(function* () {
200203 // A t3.json iconPath takes precedence over the well-known locations.
201204 const projectFile = yield* projectFileLoader.load(projectCwd);
202205 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 );
204211 if (existing) {
205212 return existing;
206213 }
207214 }
208215
209216 for (const candidate of FAVICON_CANDIDATES) {
210 const existing = yield* findExistingFile(projectCwd, [candidate]);
217 const existing = yield* findExistingFile(projectCwd, [candidate], "workspace");
211218 if (existing) {
212219 return existing;
213220 }
......@@ -251,7 +258,7 @@ export const make = Effect.gen(function* () {
251258 if (!href) {
252259 continue;
253260 }
254 const existing = yield* findExistingFile(projectCwd, resolveIconHref(href));
261 const existing = yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace");
255262 if (existing) {
256263 return existing;
257264 }
apps/web/src/components/CommandPalette.tsx+10-17
......@@ -95,7 +95,13 @@ import { isPreviewFocused } from "../lib/previewFocus";
9595import { isTerminalFocused } from "../lib/terminalFocus";
9696import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
9797import { 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";
99105import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
100106import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
101107import {
......@@ -147,7 +153,7 @@ import {
147153 type ProviderInstanceEntry,
148154} from "../providerInstances";
149155import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
150import { CommandDialog, CommandDialogPopup } from "./ui/command";
156import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
151157import { Button } from "./ui/button";
152158import { Kbd, KbdGroup } from "./ui/kbd";
153159import { stackedThreadToast, toastManager } from "./ui/toast";
......@@ -175,16 +181,6 @@ function projectFavicon(project: Project) {
175181 );
176182}
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
188184function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
189185 if (os === "windows") {
190186 return "Win32";
......@@ -2418,17 +2414,14 @@ function OpenCommandPaletteDialog(props: {
24182414 : undefined;
24192415
24202416 const footerTrailing = canOpenProjectFromFileManager ? (
2421 <Button
2422 variant="ghost"
2423 size="xs"
2424 className="h-auto px-2 text-muted-foreground text-xs hover:bg-transparent hover:text-foreground"
2417 <CommandFooterAction
24252418 disabled={isPickingProjectFolder}
24262419 onClick={() => {
24272420 void handleOpenProjectFromFileManager();
24282421 }}
24292422 >
24302423 {`Open in ${fileManagerName}`}
2431 </Button>
2424 </CommandFooterAction>
24322425 ) : null;
24332426
24342427 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 @@
11import { useAtomValue } from "@effect/atom-react";
22import type { EnvironmentId } from "@t3tools/contracts";
3import { isWindowsAbsolutePath } from "@t3tools/shared/path";
34import { useMemo, useState } from "react";
45
56import { primaryServerKeybindingsAtom } from "~/state/server";
67import { useTheme } from "~/hooks/useTheme";
8import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils";
79import { CommandPaletteContent } from "../CommandPaletteContent";
810import type { CommandPaletteActionItem } from "../CommandPalette.logic";
911import { CommandPaletteResults } from "../CommandPaletteResults";
......@@ -13,24 +15,30 @@ import {
1315 PROJECT_FILE_PICKER_RESULT_LIMIT,
1416} from "../files/ProjectFilePicker.logic";
1517import { useProjectFilePickerQuery } from "../files/projectFilesQueryState";
16import { CommandDialog, CommandDialogPopup } from "../ui/command";
18import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "../ui/command";
19import { toastManager } from "../ui/toast";
1720
1821function emptyMessage(query: string, error: string | null, isPending: boolean): string {
1922 if (error) return error;
2023 if (isPending) return query.trim() ? "Searching project files…" : "Indexing project files…";
2124 return query.trim() ? "No matching image files." : "No image files found.";
2225}
26export function canPickExternalProjectFavicon(cwd: string, platform: string): boolean {
27 return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd);
28}
2329
2430export function ProjectFaviconPickerDialog(props: {
2531 readonly cwd: string;
2632 readonly environmentId: EnvironmentId;
2733 readonly onOpenChange: (open: boolean) => void;
34 readonly onPickExternal?: () => Promise<string | null>;
2835 readonly onSelect: (path: string) => void;
2936 readonly open: boolean;
3037 readonly projectName: string;
3138}) {
3239 const [query, setQuery] = useState("");
3340 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
41 const [isPickingExternal, setIsPickingExternal] = useState(false);
3442 const result = useProjectFilePickerQuery(
3543 props.environmentId,
3644 props.cwd,
......@@ -40,6 +48,10 @@ export function ProjectFaviconPickerDialog(props: {
4048 );
4149 const { resolvedTheme } = useTheme();
4250 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
51 const pickExternal = props.onPickExternal;
52 const fileManagerName = getLocalFileManagerName(
53 typeof navigator === "undefined" ? "" : navigator.platform,
54 );
4355 const items = useMemo<CommandPaletteActionItem[]>(
4456 () =>
4557 getProjectFilePickerMatches(result.entries, result.matchedQuery).map((match) => ({
......@@ -67,6 +79,33 @@ export function ProjectFaviconPickerDialog(props: {
6779 autoHighlight="always"
6880 escapeLabel="Close"
6981 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 }
70109 inputProps={{ placeholder: "Search image files…" }}
71110 mode="none"
72111 onItemHighlighted={(value) => {
apps/web/src/components/settings/ProjectSettingsPanel.tsx+17-1
......@@ -108,7 +108,10 @@ import {
108108 SettingsRow,
109109 SettingsSection,
110110} from "./settingsLayout";
111import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog";
111import {
112 canPickExternalProjectFavicon,
113 ProjectFaviconPickerDialog,
114} from "./ProjectFaviconPickerDialog";
112115
113116export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
114117 repository: "Group by repository",
......@@ -286,6 +289,7 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
286289
287290function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
288291 const navigate = useNavigate();
292 const primaryEnvironmentId = usePrimaryEnvironmentId();
289293 const settings = usePrimarySettings();
290294 const updateClientSettings = useUpdateClientSettings();
291295 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
......@@ -319,6 +323,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
319323 (member) => member.environmentId === group.environmentId && member.id === group.id,
320324 ) ?? group.memberProjects[0]!;
321325 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
323336 const threadCountByMember = useMemo(() => {
324337 const counts = new Map<string, number>();
......@@ -1166,6 +1179,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
11661179 cwd={representative.workspaceRoot}
11671180 environmentId={representative.environmentId}
11681181 onOpenChange={setFaviconPickerOpen}
1182 {...(pickProjectFavicon
1183 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
1184 : {})}
11691185 onSelect={(path) => void setFaviconPath(path)}
11701186 open={faviconPickerOpen}
11711187 projectName={group.displayName}
apps/web/src/components/ui/command.tsx+16
......@@ -16,6 +16,7 @@ import {
1616 AutocompleteSeparator,
1717} from "~/components/ui/autocomplete";
1818import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
19import { Button } from "~/components/ui/button";
1920
2021const CommandDialog = CommandDialogPrimitive.Root;
2122
......@@ -225,6 +226,20 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {
225226 );
226227}
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
228243export {
229244 CommandCreateHandle,
230245 Command,
......@@ -234,6 +249,7 @@ export {
234249 CommandDialogTrigger,
235250 CommandEmpty,
236251 CommandFooter,
252 CommandFooterAction,
237253 CommandGroup,
238254 CommandGroupLabel,
239255 CommandInput,
apps/web/src/lib/utils.test.ts+11-1
......@@ -1,5 +1,15 @@
11import { 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
414describe("isWindowsPlatform", () => {
515 it("matches Windows platform identifiers", () => {
apps/web/src/lib/utils.ts+10
......@@ -20,6 +20,16 @@ export function isLinuxPlatform(platform: string): boolean {
2020 return /linux/i.test(platform);
2121}
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
2333export function randomHex(byteLength: number): string {
2434 return Encoding.encodeHex(globalThis.crypto.getRandomValues(new Uint8Array(byteLength)));
2535}
packages/contracts/src/ipc.ts+2
......@@ -1110,6 +1110,8 @@ export interface DesktopBridge {
11101110 setWslDistro: (distro: string | null) => Promise<DesktopWslState>;
11111111 setWslOnly: (enabled: boolean) => Promise<DesktopWslState>;
11121112 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>;
11131115 /**
11141116 * Multi-select JSON file picker that opens in the VS Code extensions
11151117 * directory when one exists. Optional: older desktop builds lack it, and