| author | |
| committer | |
| log | 44e4a7071d84c95f4d273cc3c6c9682166502745 |
| tree | 22c4b3413a02e28e8c0affa72ec015342aaf6244 |
| parent | b381fdb12cb7cb241e2b8aca84941375f3fb43f4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 55 | 55 | ||
| 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 | } |
| 88 | 89 | ||
| 89 | export class ElectronDialog extends Context.Service< | 90 | export 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); |
| 83 | 84 | ||
| 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 @@ |
| 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 2 | export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; | ||
| 2 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; | 3 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; |
| 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; | 4 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 5 | export 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"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import { vi } from "vite-plus/test"; | ||
| 5 | 6 | ||
| 6 | import type * as Electron from "electron"; | 7 | import type * as Electron from "electron"; |
| 7 | 8 | ||
| 8 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; | 9 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; |
| 9 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | 10 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 11 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | ||
| 10 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | 12 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 11 | import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; | 13 | import { |
| 14 | getLocalEnvironmentBootstraps, | ||
| 15 | getWindowFullscreenState, | ||
| 16 | pickProjectFavicon, | ||
| 17 | } from "./window.ts"; | ||
| 12 | 18 | ||
| 13 | const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { | 19 | const 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 | |||
| 156 | describe("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"; |
| 15 | import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; | ||
| 15 | import { isCommandAvailable } from "@t3tools/shared/shell"; | 16 | import { isCommandAvailable } from "@t3tools/shared/shell"; |
| 16 | import * as NodeOS from "node:os"; | 17 | import * as NodeOS from "node:os"; |
| 17 | import * as FileSystem from "effect/FileSystem"; | 18 | import * 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 | }); |
| 236 | 237 | ||
| 238 | export 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 | |||
| 237 | export const setTheme = DesktopIpc.makeIpcMethod({ | 260 | export 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 | ); |
| 289 | 289 | ||
| 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 | ]); |
| 92 | type AssetClaims = typeof AssetClaimsSchema.Type; | 98 | type AssetClaims = typeof AssetClaimsSchema.Type; |
| 93 | 99 | ||
| ... | @@ -124,6 +130,17 @@ const optionOnNotFound = <A, R>( | ... | @@ -124,6 +130,17 @@ const optionOnNotFound = <A, R>( |
| 124 | }), | 130 | }), |
| 125 | ); | 131 | ); |
| 126 | 132 | ||
| 133 | const 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 | |||
| 127 | const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( | 144 | const 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 = relativePath | 332 | 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 | } |
| 443 | 479 | ||
| 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 | ); |
| 93 | 93 | ||
| 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* workspacePaths | 143 | 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 it | 194 | // 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 | } |
| 208 | 215 | ||
| 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"; |
| 95 | import { isTerminalFocused } from "../lib/terminalFocus"; | 95 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 96 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; | 96 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; |
| 97 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; | 97 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; |
| 98 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; | 98 | import { |
| 99 | cn, | ||
| 100 | getLocalFileManagerName, | ||
| 101 | isMacPlatform, | ||
| 102 | isWindowsPlatform, | ||
| 103 | newProjectId, | ||
| 104 | } from "../lib/utils"; | ||
| 99 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 105 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 100 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; | 106 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; |
| 101 | import { | 107 | import { |
| ... | @@ -147,7 +153,7 @@ import { | ... | @@ -147,7 +153,7 @@ import { |
| 147 | type ProviderInstanceEntry, | 153 | type ProviderInstanceEntry, |
| 148 | } from "../providerInstances"; | 154 | } from "../providerInstances"; |
| 149 | import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; | 155 | import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; |
| 150 | import { CommandDialog, CommandDialogPopup } from "./ui/command"; | 156 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; |
| 151 | import { Button } from "./ui/button"; | 157 | import { Button } from "./ui/button"; |
| 152 | import { Kbd, KbdGroup } from "./ui/kbd"; | 158 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 153 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 159 | import { 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 | } |
| 177 | 183 | ||
| 178 | function 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 | |||
| 188 | function getEnvironmentBrowsePlatform(os: string | null | undefined): string { | 184 | function 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; |
| 2419 | 2415 | ||
| 2420 | const footerTrailing = canOpenProjectFromFileManager ? ( | 2416 | const footerTrailing = canOpenProjectFromFileManager ? ( |
| 2421 | <Button | 2417 | <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; |
| 2433 | 2426 | ||
| 2434 | return ( | 2427 | return ( |
apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx created+125| ... | @@ -0,0 +1,125 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { ReactElement } from "react"; | ||
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { visitElements } from "../../test/reactElementTree"; | ||
| 6 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; | ||
| 7 | |||
| 8 | vi.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 | |||
| 18 | vi.mock("react/compiler-runtime", async () => { | ||
| 19 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | ||
| 20 | return { c: reactHookHarness.useMemoCache }; | ||
| 21 | }); | ||
| 22 | |||
| 23 | vi.mock("@effect/atom-react", () => ({ | ||
| 24 | useAtomValue: () => ({}), | ||
| 25 | })); | ||
| 26 | |||
| 27 | vi.mock("~/state/server", () => ({ | ||
| 28 | primaryServerKeybindingsAtom: Symbol("keybindings"), | ||
| 29 | })); | ||
| 30 | |||
| 31 | vi.mock("~/hooks/useTheme", () => ({ | ||
| 32 | useTheme: () => ({ resolvedTheme: "dark" }), | ||
| 33 | })); | ||
| 34 | |||
| 35 | vi.mock("../files/projectFilesQueryState", () => ({ | ||
| 36 | useProjectFilePickerQuery: () => ({ | ||
| 37 | entries: [], | ||
| 38 | error: null, | ||
| 39 | isPending: false, | ||
| 40 | matchedQuery: "", | ||
| 41 | }), | ||
| 42 | })); | ||
| 43 | |||
| 44 | vi.mock("../ui/toast", () => ({ | ||
| 45 | toastManager: { add: vi.fn() }, | ||
| 46 | })); | ||
| 47 | |||
| 48 | import { toastManager } from "../ui/toast"; | ||
| 49 | import { | ||
| 50 | canPickExternalProjectFavicon, | ||
| 51 | ProjectFaviconPickerDialog, | ||
| 52 | } from "./ProjectFaviconPickerDialog"; | ||
| 53 | |||
| 54 | describe("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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 3 | import { useMemo, useState } from "react"; | 4 | import { useMemo, useState } from "react"; |
| 4 | 5 | ||
| 5 | import { primaryServerKeybindingsAtom } from "~/state/server"; | 6 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 6 | import { useTheme } from "~/hooks/useTheme"; | 7 | import { useTheme } from "~/hooks/useTheme"; |
| 8 | import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils"; | ||
| 7 | import { CommandPaletteContent } from "../CommandPaletteContent"; | 9 | import { CommandPaletteContent } from "../CommandPaletteContent"; |
| 8 | import type { CommandPaletteActionItem } from "../CommandPalette.logic"; | 10 | import type { CommandPaletteActionItem } from "../CommandPalette.logic"; |
| 9 | import { CommandPaletteResults } from "../CommandPaletteResults"; | 11 | import { 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"; |
| 15 | import { useProjectFilePickerQuery } from "../files/projectFilesQueryState"; | 17 | import { useProjectFilePickerQuery } from "../files/projectFilesQueryState"; |
| 16 | import { CommandDialog, CommandDialogPopup } from "../ui/command"; | 18 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "../ui/command"; |
| 19 | import { toastManager } from "../ui/toast"; | ||
| 17 | 20 | ||
| 18 | function emptyMessage(query: string, error: string | null, isPending: boolean): string { | 21 | function 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 | } |
| 26 | export function canPickExternalProjectFavicon(cwd: string, platform: string): boolean { | ||
| 27 | return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd); | ||
| 28 | } | ||
| 23 | 29 | ||
| 24 | export function ProjectFaviconPickerDialog(props: { | 30 | export 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"; |
| 111 | import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog"; | 111 | import { |
| 112 | canPickExternalProjectFavicon, | ||
| 113 | ProjectFaviconPickerDialog, | ||
| 114 | } from "./ProjectFaviconPickerDialog"; | ||
| 112 | 115 | ||
| 113 | export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { | 116 | export 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 }) { |
| 286 | 289 | ||
| 287 | function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { | 290 | function 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; | ||
| 322 | 335 | ||
| 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"; |
| 18 | import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; | 18 | import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; |
| 19 | import { Button } from "~/components/ui/button"; | ||
| 19 | 20 | ||
| 20 | const CommandDialog = CommandDialogPrimitive.Root; | 21 | const CommandDialog = CommandDialogPrimitive.Root; |
| 21 | 22 | ||
| ... | @@ -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 | } |
| 227 | 228 | ||
| 229 | function 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 | |||
| 228 | export { | 243 | export { |
| 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 @@ |
| 1 | import { describe, assert, it } from "vite-plus/test"; | 1 | import { describe, assert, it } from "vite-plus/test"; |
| 2 | import { isWindowsPlatform } from "./utils"; | 2 | import { getLocalFileManagerName, isWindowsPlatform } from "./utils"; |
| 3 | |||
| 4 | describe("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 | }); | ||
| 3 | 13 | ||
| 4 | describe("isWindowsPlatform", () => { | 14 | describe("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 | } |
| 22 | 22 | ||
| 23 | export 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 | |||
| 23 | export function randomHex(byteLength: number): string { | 33 | export 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 extensions | 1116 | * Multi-select JSON file picker that opens in the VS Code extensions |
| 1115 | * directory when one exists. Optional: older desktop builds lack it, and | 1117 | * directory when one exists. Optional: older desktop builds lack it, and |