From aa3d9cbf0e6864505e2e5d58a69aafde9c1da04e Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 15:02:59 -0700 Subject: [PATCH 01/10] feat: add user-local project icons --- apps/server/src/assets/AssetAccess.test.ts | 41 +++- apps/server/src/assets/AssetAccess.ts | 74 ++++-- .../project/ProjectFaviconResolver.test.ts | 44 ++++ .../src/project/ProjectFaviconResolver.ts | 39 +++- apps/server/src/serverSettings.test.ts | 6 + apps/server/src/ws.ts | 1 + apps/web/src/components/ProjectFavicon.tsx | 6 + .../components/ProjectIconSettings.test.ts | 31 +++ .../src/components/ProjectIconSettings.tsx | 221 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 22 +- apps/web/src/components/SidebarV2.tsx | 98 +++++++- packages/contracts/src/assets.ts | 3 + packages/contracts/src/server.ts | 1 + packages/contracts/src/settings.test.ts | 26 +++ packages/contracts/src/settings.ts | 8 + packages/shared/src/serverSettings.test.ts | 20 ++ packages/shared/src/serverSettings.ts | 1 + 17 files changed, 608 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/ProjectIconSettings.test.ts create mode 100644 apps/web/src/components/ProjectIconSettings.tsx diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42fd3f900e5a..6981ce185368 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -7,10 +7,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Stream from "effect/Stream"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; @@ -25,6 +27,7 @@ const testLayer = Layer.mergeAll( Layer.provide(WorkspacePaths.layer), Layer.provide(T3ProjectFileLoader.layer), ), + ServerSettings.ServerSettingsService.layerTest(), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -245,6 +248,42 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues exact capabilities for configured project icons outside the workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-root-", + }); + const iconDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-custom-", + }); + const iconPath = path.join(iconDirectory, "custom.svg"); + yield* fileSystem.writeFileString(iconPath, ""); + const canonicalIconPath = yield* fileSystem.realPath(iconPath); + const settings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + projectIcons: { [root]: iconPath }, + }), + updateSettings: () => Effect.die("not implemented"), + streamChanges: Stream.empty, + }); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, revision: iconPath }, + }).pipe(Effect.provideService(ServerSettings.ServerSettingsService, settings)); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ kind: "file", path: canonicalIconPath }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("preserves structured project favicon resolution causes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b8..7a896fa60eac 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -38,6 +38,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -84,6 +85,12 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(2), + kind: Schema.Literal("project-icon"), + absolutePath: Schema.NullOr(Schema.String), + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -282,8 +289,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( + const settings = yield* ServerSettings.ServerSettingsService; + const serverSettings = yield* settings.getSettings.pipe( Effect.mapError( (cause) => new AssetProjectFaviconResolutionError({ @@ -292,39 +299,40 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if ( - relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const customIconPath = serverSettings.projectIcons[workspaceRoot]; + const faviconPath = yield* faviconResolver + .resolvePath(workspaceRoot, customIconPath === undefined ? undefined : { customIconPath }) + .pipe( Effect.mapError( (cause) => - new AssetProjectFaviconInspectionError({ + new AssetProjectFaviconResolutionError({ resource: input.resource, cause, }), ), - )) - ) { - return yield* new AssetProjectFaviconNotFoundError({ - resource: input.resource, - }); + ); + const canonicalFaviconPath = faviconPath + ? yield* optionOnNotFound(fileSystem.realPath(faviconPath)).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ) + : Option.none(); + if (faviconPath && Option.isNone(canonicalFaviconPath)) { + return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource }); } claims = { - version: 1, - kind: "project-favicon", - workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceResolutionError({ - resource: input.resource, - cause, - }), - ), - ), - relativePath, + version: 2, + kind: "project-icon", + absolutePath: Option.getOrNull(canonicalFaviconPath), expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; + fileName = faviconPath ? path.basename(faviconPath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } @@ -396,6 +404,22 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( }); return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; } + if (claims.kind === "project-icon") { + if (claims.absolutePath === null) return null; + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* optionOnNotFound(fileSystem.stat(claims.absolutePath)).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to inspect configured project icon.", { + path: claims.absolutePath, + cause, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + return Option.isSome(info) && info.value.type === "File" + ? ({ kind: "file", path: claims.absolutePath } satisfies ResolvedAsset) + : null; + } const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a50..9c526abb342b 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -77,6 +77,50 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("prefers a configured icon outside the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const iconDirectory = yield* makeTempDir; + yield* writeTextFile(iconDirectory, "custom.svg", "custom"); + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd, { + customIconPath: `${iconDirectory}/custom.svg`, + }); + + expect(resolved).toBe(`${iconDirectory}/custom.svg`); + }), + ); + + it.effect("resolves configured relative paths from the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, ".local/icon.svg", "custom"); + + const resolved = yield* resolver.resolvePath(cwd, { + customIconPath: ".local/icon.svg", + }); + + expect(resolved).toBe(`${cwd}/.local/icon.svg`); + }), + ); + + it.effect("falls back to discovery when the configured icon does not exist", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd, { + customIconPath: "/missing/custom.svg", + }); + + expect(resolved).toBe(`${cwd}/favicon.svg`); + }), + ); + it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630b..245865e7e8bf 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -6,6 +6,8 @@ * * @module ProjectFaviconResolver */ +import * as NodeOS from "node:os"; + import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -91,6 +93,7 @@ export class ProjectFaviconResolver extends Context.Service< */ readonly resolvePath: ( cwd: string, + options?: { readonly customIconPath?: string }, ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -103,6 +106,16 @@ function extractIconHref(source: string): string | null { return null; } +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return NodeOS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(NodeOS.homedir(), input.slice(2)); + } + return input; +} + const optionOnNotFound = ( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => @@ -168,7 +181,7 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd) { + )(function* (cwd, options) { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -179,6 +192,30 @@ export const make = Effect.gen(function* () { }), ), ); + // User-local settings override checked-in metadata and automatic discovery. + // Relative paths are resolved from the workspace; absolute and home-relative + // paths may point at a central icon directory outside the repository. + if (options?.customIconPath !== undefined) { + const expandedIconPath = expandHomePath(options.customIconPath.trim(), path); + const customIconPath = path.isAbsolute(expandedIconPath) + ? path.resolve(expandedIconPath) + : path.resolve(projectCwd, expandedIconPath); + const customIconStats = yield* optionOnNotFound(fileSystem.stat(customIconPath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + absolutePath: customIconPath, + cause, + }), + ), + ); + if (Option.isSome(customIconStats) && customIconStats.value.type === "File") { + return customIconPath; + } + } + // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 50ca810a95a5..54f1f197987e 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -555,6 +555,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development", + projectIcons: { + "/workspace/t3code": "~/.config/t3code/icons/t3code.svg", + }, observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -577,6 +580,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(raw), { addProjectBaseDirectory: "~/Development", + projectIcons: { + "/workspace/t3code": "~/.config/t3code/icons/t3code.svg", + }, observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a8be25a728a..81ff42f7e105 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1093,6 +1093,7 @@ const makeWsRpcLayer = ( auth, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, + settingsConfigPath: config.settingsPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832ff..d02f9cc14cb7 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -4,6 +4,7 @@ import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; +import { useEnvironmentSettings } from "../hooks/useSettings"; const loadedProjectFaviconSrcs = new Set(); @@ -13,9 +14,14 @@ export function ProjectFavicon(input: { className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { + const configuredIconPath = useEnvironmentSettings( + input.environmentId, + (settings) => settings.projectIcons[input.cwd], + ); const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", cwd: input.cwd, + ...(configuredIconPath ? { revision: configuredIconPath } : {}), }); const FallbackIcon = input.fallbackIcon ?? FolderIcon; diff --git a/apps/web/src/components/ProjectIconSettings.test.ts b/apps/web/src/components/ProjectIconSettings.test.ts new file mode 100644 index 000000000000..2e16bd6fe3f6 --- /dev/null +++ b/apps/web/src/components/ProjectIconSettings.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { replaceProjectIconSetting } from "./ProjectIconSettings"; + +describe("replaceProjectIconSetting", () => { + it("sets and trims a project icon without changing other projects", () => { + expect( + replaceProjectIconSetting( + { "/workspace/one": "/icons/one.svg" }, + "/workspace/two", + " ~/icons/two.svg ", + ), + ).toEqual({ + "/workspace/one": "/icons/one.svg", + "/workspace/two": "~/icons/two.svg", + }); + }); + + it("removes only the selected project when the path is blank", () => { + expect( + replaceProjectIconSetting( + { + "/workspace/one": "/icons/one.svg", + "/workspace/two": "/icons/two.svg", + }, + "/workspace/one", + " ", + ), + ).toEqual({ "/workspace/two": "/icons/two.svg" }); + }); +}); diff --git a/apps/web/src/components/ProjectIconSettings.tsx b/apps/web/src/components/ProjectIconSettings.tsx new file mode 100644 index 000000000000..e4174a12f056 --- /dev/null +++ b/apps/web/src/components/ProjectIconSettings.tsx @@ -0,0 +1,221 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useState } from "react"; + +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; +import { useAtomCommand } from "../state/use-atom-command"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +export interface ProjectIconTarget { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string | null; + readonly title: string; + readonly workspaceRoot: string; +} + +export function replaceProjectIconSetting( + projectIcons: Readonly>, + workspaceRoot: string, + iconPath: string, +): Record { + const next = { ...projectIcons }; + const trimmedPath = iconPath.trim(); + if (trimmedPath.length === 0) { + delete next[workspaceRoot]; + } else { + next[workspaceRoot] = trimmedPath; + } + return next; +} + +function useProjectIconSetting(target: ProjectIconTarget) { + const projectIcons = useEnvironmentSettings( + target.environmentId, + (settings) => settings.projectIcons, + ); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); + const iconPath = projectIcons[target.workspaceRoot] ?? ""; + const settingsPath = + serverConfigs.get(target.environmentId)?.settingsConfigPath ?? "settings.json"; + + const saveIconPath = useCallback( + async (nextPath: string): Promise => { + const nextProjectIcons = replaceProjectIconSetting( + projectIcons, + target.workspaceRoot, + nextPath, + ); + if ( + nextProjectIcons[target.workspaceRoot] === projectIcons[target.workspaceRoot] && + Object.keys(nextProjectIcons).length === Object.keys(projectIcons).length + ) { + return true; + } + const result = await updateServerSettings({ + environmentId: target.environmentId, + input: { patch: { projectIcons: nextProjectIcons } }, + }); + if (result._tag === "Success") { + return true; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update project icon", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return false; + }, + [projectIcons, target.environmentId, target.workspaceRoot, updateServerSettings], + ); + + return { iconPath, saveIconPath, settingsPath }; +} + +export function ProjectIconPathField({ target }: { readonly target: ProjectIconTarget }) { + const { iconPath, saveIconPath, settingsPath } = useProjectIconSetting(target); + + return ( + + ); +} + +function ProjectIconDialogContent({ + target, + onClose, +}: { + readonly target: ProjectIconTarget; + readonly onClose: () => void; +}) { + const { iconPath, saveIconPath, settingsPath } = useProjectIconSetting(target); + const [draftPath, setDraftPath] = useState(iconPath); + const submit = async (nextPath: string) => { + if (await saveIconPath(nextPath)) { + toastManager.add({ + type: "success", + title: nextPath.trim() ? "Project icon updated" : "Project icon reset", + description: target.title, + }); + onClose(); + } + }; + + return ( + + + Project icon + + Set a user-local icon for {target.title} without changing the project repository. + + + +
+ +
+

{target.title}

+

+ {target.workspaceRoot} +

+
+
+ +

+ Use an absolute path, ~/…, or a path relative to the project. The setting is stored in{" "} + {settingsPath}. +

+ {target.environmentLabel ? ( +

Environment: {target.environmentLabel}

+ ) : null} +
+ + {iconPath ? ( + + ) : null} + + + +
+ ); +} + +export function ProjectIconDialog({ + target, + onOpenChange, +}: { + readonly target: ProjectIconTarget | null; + readonly onOpenChange: (open: boolean) => void; +}) { + return ( + + {target ? ( + onOpenChange(false)} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b05b3a39d904..a57c1341ce64 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectIconDialog, type ProjectIconTarget } from "./ProjectIconSettings"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -1203,6 +1204,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [projectRenameTitle, setProjectRenameTitle] = useState(""); const [projectGroupingTarget, setProjectGroupingTarget] = useState(null); + const [projectIconTarget, setProjectIconTarget] = useState(null); const [projectGroupingSelection, setProjectGroupingSelection] = useState< SidebarProjectGroupingMode | "inherit" >("inherit"); @@ -1586,7 +1588,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const actionHandlers = new Map Promise | void>(); const makeLeaf = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "rename" | "grouping" | "icon" | "copy-path" | "delete", member: SidebarProjectGroupMember, options?: { destructive?: boolean; @@ -1602,6 +1604,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec case "grouping": openProjectGroupingDialog(member); return; + case "icon": + setProjectIconTarget({ + environmentId: member.environmentId, + environmentLabel: member.environmentLabel, + title: member.title, + workspaceRoot: member.workspaceRoot, + }); + return; case "copy-path": copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }); return; @@ -1619,7 +1629,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; const buildTargetedItem = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "rename" | "grouping" | "icon" | "copy-path" | "delete", label: string, options?: { destructive?: boolean; @@ -1655,6 +1665,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ buildTargetedItem("rename", "Rename"), buildTargetedItem("grouping", "Group into..."), + buildTargetedItem("icon", "Project icon..."), buildTargetedItem("copy-path", "Copy Path"), buildTargetedItem("delete", "Remove", { destructive: true, @@ -2405,6 +2416,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec + { + if (!open) setProjectIconTarget(null); + }} + /> + { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ed9eadd907b7..7747732d9d53 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -132,6 +132,11 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; +import { + ProjectIconDialog, + ProjectIconPathField, + type ProjectIconTarget, +} from "./ProjectIconSettings"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; @@ -385,6 +390,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isRenaming: boolean; renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onProjectIconContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; @@ -552,6 +561,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onContextMenu, threadRef], ); + const handleProjectIconContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + props.onProjectIconContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }); + }, + [props.onProjectIconContextMenu, threadRef], + ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.target !== event.currentTarget) return; @@ -759,6 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { !props.isActive && "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0", )} + onContextMenu={handleProjectIconContextMenu} >
- + + + {props.projectTitle ? ( ( null, ); + const [projectIconTarget, setProjectIconTarget] = useState(null); const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( @@ -1144,6 +1168,19 @@ export default function SidebarV2() { ), [projectGroups], ); + const projectMemberByKey = useMemo( + () => + new Map( + projectGroups.flatMap((group) => + group.memberProjects.map( + (member) => [`${member.environmentId}:${member.id}`, member] as const, + ), + ), + ), + [projectGroups], + ); + const projectMemberByKeyRef = useRef(projectMemberByKey); + projectMemberByKeyRef.current = projectMemberByKey; // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. @@ -1542,6 +1579,42 @@ export default function SidebarV2() { // event and defeat row memoization during streaming. const threadByKeyRef = useRef(threadByKey); threadByKeyRef.current = threadByKey; + const handleProjectIconContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const thread = threadByKeyRef.current.get(scopedThreadKey(threadRef)); + if (!thread) return; + const member = projectMemberByKeyRef.current.get( + `${thread.environmentId}:${thread.projectId}`, + ); + if (!member) return; + const configuredIcon = serverConfigs.get(member.environmentId)?.settings.projectIcons[ + member.workspaceRoot + ]; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { + id: "configure-project-icon", + label: configuredIcon ? "Change project icon..." : "Set project icon...", + }, + ], + position, + ), + ); + if (clicked._tag === "Failure" || clicked.value !== "configure-project-icon") return; + setProjectIconTarget({ + environmentId: member.environmentId, + environmentLabel: member.environmentLabel, + title: member.title, + workspaceRoot: member.workspaceRoot, + }); + })(); + }, + [serverConfigs], + ); // handleNewThread is inherently unstable (depends on the projects list); // a ref keeps it out of attemptSettle's dependency array. const handleNewThreadRef = useRef(newThreadContext.handleNewThread); @@ -2447,6 +2520,7 @@ export default function SidebarV2() { isRenaming={renamingThreadKey === threadKey} renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} onContextMenu={handleThreadContextMenu} + onProjectIconContextMenu={handleProjectIconContextMenu} onSettle={attemptSettle} onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} @@ -2560,6 +2634,12 @@ export default function SidebarV2() { ) : null} + { + if (!open) setProjectIconTarget(null); + }} + /> { @@ -2671,6 +2751,14 @@ export default function SidebarV2() { +
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c07aa19f8159..21d43bfccfb4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -166,26 +166,44 @@ describe("ServerSettings.sourceControlWritingStyle", () => { describe("ServerSettings project icons", () => { it("defaults to an empty map for existing settings files", () => { expect(decodeServerSettings({}).projectIcons).toEqual({}); + expect(decodeServerSettings({}).projectIconsByGitRemote).toEqual({}); }); - it("trims project roots and icon paths in settings and patches", () => { + it("trims project roots, git remotes, and icon paths in settings and patches", () => { const input = { projectIcons: { " /workspace/t3code ": " ~/.config/t3code/icons/t3code.svg ", }, + projectIconsByGitRemote: { + " github.com/t3tools/t3code ": " ~/.config/t3code/icons/t3code.svg ", + }, }; expect(decodeServerSettings(input).projectIcons).toEqual({ "/workspace/t3code": "~/.config/t3code/icons/t3code.svg", }); + expect(decodeServerSettings(input).projectIconsByGitRemote).toEqual({ + "github.com/t3tools/t3code": "~/.config/t3code/icons/t3code.svg", + }); expect(decodeServerSettingsPatch(input).projectIcons).toEqual({ "/workspace/t3code": "~/.config/t3code/icons/t3code.svg", }); + expect(decodeServerSettingsPatch(input).projectIconsByGitRemote).toEqual({ + "github.com/t3tools/t3code": "~/.config/t3code/icons/t3code.svg", + }); }); - it("rejects empty project roots and icon paths", () => { + it("rejects empty project roots, git remotes, and icon paths", () => { expect(() => decodeServerSettings({ projectIcons: { "": "/icons/project.svg" } })).toThrow(); expect(() => decodeServerSettingsPatch({ projectIcons: { "/workspace": " " } })).toThrow(); + expect(() => + decodeServerSettings({ projectIconsByGitRemote: { "": "/icons/project.svg" } }), + ).toThrow(); + expect(() => + decodeServerSettingsPatch({ + projectIconsByGitRemote: { "github.com/example/project": " " }, + }), + ).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3fda29973d8c..7c303cc769ff 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -417,6 +417,7 @@ export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); const ProjectIconPath = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); const ProjectIconWorkspaceRoot = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); +const ProjectIconGitRemote = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -436,6 +437,9 @@ export const ServerSettings = Schema.Struct({ projectIcons: Schema.Record(ProjectIconWorkspaceRoot, ProjectIconPath).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + projectIconsByGitRemote: Schema.Record(ProjectIconGitRemote, ProjectIconPath).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -576,6 +580,9 @@ export const ServerSettingsPatch = Schema.Struct({ addProjectBaseDirectory: Schema.optionalKey(TrimmedString), // Whole-map replacement. Omitting a key removes that project's override. projectIcons: Schema.optionalKey(Schema.Record(ProjectIconWorkspaceRoot, ProjectIconPath)), + // Whole-map replacement. Keys are normalized repository identities such as + // github.com/t3tools/t3code, independent of clone URL or local path. + projectIconsByGitRemote: Schema.optionalKey(Schema.Record(ProjectIconGitRemote, ProjectIconPath)), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( Schema.Struct({ diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 8ae420ddc5c3..b95968eb87cd 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -303,16 +303,26 @@ describe("serverSettings helpers", () => { "/workspace/one": "/icons/one.svg", "/workspace/two": "/icons/two.svg", }, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one.svg", + "github.com/example/two": "/icons/two.svg", + }, }; - expect( - applyServerSettingsPatch(current, { - projectIcons: { - "/workspace/two": "/icons/two-next.svg", - }, - }).projectIcons, - ).toEqual({ + const next = applyServerSettingsPatch(current, { + projectIcons: { + "/workspace/two": "/icons/two-next.svg", + }, + projectIconsByGitRemote: { + "github.com/example/two": "/icons/two-next.svg", + }, + }); + + expect(next.projectIcons).toEqual({ "/workspace/two": "/icons/two-next.svg", }); + expect(next.projectIconsByGitRemote).toEqual({ + "github.com/example/two": "/icons/two-next.svg", + }); }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index f170d9053a04..1007e535dbc7 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -125,6 +125,9 @@ export function applyServerSettingsPatch( const nextWithReplacements = { ...next, ...(patch.projectIcons !== undefined ? { projectIcons: patch.projectIcons } : {}), + ...(patch.projectIconsByGitRemote !== undefined + ? { projectIconsByGitRemote: patch.projectIconsByGitRemote } + : {}), ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), From a189fe4ff8734b432fbc7afbecbb7ba7a0524a0a Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 16:24:29 -0700 Subject: [PATCH 03/10] fix: harden project icon settings --- apps/server/src/assets/AssetAccess.test.ts | 42 +++++++++ apps/server/src/assets/AssetAccess.ts | 16 +++- .../web/src/components/ProjectFavicon.test.ts | 77 +++++++++++++++++ apps/web/src/components/ProjectFavicon.tsx | 36 ++++++-- .../components/ProjectIconSettings.test.ts | 60 +++++++++++++ .../src/components/ProjectIconSettings.tsx | 83 ++++++++---------- packages/contracts/src/settings.test.ts | 28 ++++++ packages/contracts/src/settings.ts | 20 +++++ packages/shared/src/serverSettings.test.ts | 86 +++++++++++++++++++ packages/shared/src/serverSettings.ts | 60 ++++++++++++- 10 files changed, 447 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/ProjectFavicon.test.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index e5d7c20fc2af..e25fbf1731ac 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -286,6 +286,48 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("rejects a configured project icon replaced by a symlink after signing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-symlink-root-", + }); + const iconDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-symlink-custom-", + }); + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-symlink-outside-", + }); + const iconPath = path.join(iconDirectory, "custom.svg"); + const outsidePath = path.join(outsideDirectory, "secret.svg"); + yield* fileSystem.writeFileString(iconPath, "icon"); + yield* fileSystem.writeFileString(outsidePath, "secret"); + const settings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + projectIcons: { [root]: iconPath }, + }), + updateSettings: () => Effect.die("not implemented"), + streamChanges: Stream.empty, + }); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, revision: iconPath }, + }).pipe(Effect.provideService(ServerSettings.ServerSettingsService, settings)); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + yield* fileSystem.remove(iconPath); + yield* fileSystem.symlink(outsidePath, iconPath); + + expect(yield* resolveAsset(token, suffix.slice(separatorIndex + 1))).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("uses a configured git-remote icon across clone paths", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 003d42e03017..1a84c9f9325b 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -416,17 +416,27 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (claims.kind === "project-icon") { if (claims.absolutePath === null) return null; const fileSystem = yield* FileSystem.FileSystem; - const info = yield* optionOnNotFound(fileSystem.stat(claims.absolutePath)).pipe( + const canonicalPath = yield* optionOnNotFound(fileSystem.realPath(claims.absolutePath)).pipe( Effect.tapError((cause) => - Effect.logError("Failed to inspect configured project icon.", { + Effect.logError("Failed to canonicalize configured project icon.", { path: claims.absolutePath, cause, }), ), Effect.orElseSucceed(() => Option.none()), ); + if (Option.isNone(canonicalPath) || canonicalPath.value !== claims.absolutePath) return null; + const info = yield* optionOnNotFound(fileSystem.stat(canonicalPath.value)).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to inspect configured project icon.", { + path: canonicalPath.value, + cause, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); return Option.isSome(info) && info.value.type === "File" - ? ({ kind: "file", path: claims.absolutePath } satisfies ResolvedAsset) + ? ({ kind: "file", path: canonicalPath.value } satisfies ResolvedAsset) : null; } diff --git a/apps/web/src/components/ProjectFavicon.test.ts b/apps/web/src/components/ProjectFavicon.test.ts new file mode 100644 index 000000000000..fa24eaa7cbcd --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { projectFaviconSettingsRevision } from "./ProjectFavicon"; + +describe("projectFaviconSettingsRevision", () => { + it("stays bounded for large remote icon maps", () => { + const projectIconsByGitRemote = Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [ + `github.com/example/${"r".repeat(900)}-${index}`, + `/icons/${"i".repeat(900)}-${index}.svg`, + ]), + ); + + const revision = projectFaviconSettingsRevision( + { projectIcons: {}, projectIconsByGitRemote }, + "/workspace/project", + ); + + expect(revision).toBeDefined(); + expect(revision?.length).toBeLessThanOrEqual(1024); + }); + + it("is stable across remote map insertion order and changes with icon settings", () => { + const first = projectFaviconSettingsRevision( + { + projectIcons: {}, + projectIconsByGitRemote: { + "github.com/example/two": "/icons/two.svg", + "github.com/example/one": "/icons/one.svg", + }, + }, + "/workspace/project", + ); + const reordered = projectFaviconSettingsRevision( + { + projectIcons: {}, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one.svg", + "github.com/example/two": "/icons/two.svg", + }, + }, + "/workspace/project", + ); + const changed = projectFaviconSettingsRevision( + { + projectIcons: {}, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one-next.svg", + "github.com/example/two": "/icons/two.svg", + }, + }, + "/workspace/project", + ); + + expect(first).toBe(reordered); + expect(changed).not.toBe(first); + }); + + it("uses only the higher-precedence workspace icon when configured", () => { + const first = projectFaviconSettingsRevision( + { + projectIcons: { "/workspace/project": "/icons/local.svg" }, + projectIconsByGitRemote: { "github.com/example/one": "/icons/one.svg" }, + }, + "/workspace/project", + ); + const remoteChanged = projectFaviconSettingsRevision( + { + projectIcons: { "/workspace/project": "/icons/local.svg" }, + projectIconsByGitRemote: { "github.com/example/one": "/icons/one-next.svg" }, + }, + "/workspace/project", + ); + + expect(remoteChanged).toBe(first); + }); +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 7f07d697797c..7e7fd497fcb1 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -8,18 +8,42 @@ import { useEnvironmentSettings } from "../hooks/useSettings"; const loadedProjectFaviconSrcs = new Set(); +function hashProjectFaviconRevision(input: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(36); +} + +export function projectFaviconSettingsRevision( + settings: { + readonly projectIcons: Readonly>; + readonly projectIconsByGitRemote: Readonly>; + }, + cwd: string, +): string | undefined { + const pathIcon = settings.projectIcons[cwd]; + const remoteEntries = Object.entries(settings.projectIconsByGitRemote); + if (!pathIcon && remoteEntries.length === 0) return undefined; + const revisionSource = JSON.stringify( + pathIcon + ? ["path", pathIcon] + : ["remotes", remoteEntries.sort(([left], [right]) => left.localeCompare(right))], + ); + return `icons:${revisionSource.length}:${hashProjectFaviconRevision(revisionSource)}`; +} + export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const configuredIconRevision = useEnvironmentSettings(input.environmentId, (settings) => { - const pathIcon = settings.projectIcons[input.cwd]; - if (pathIcon) return `path:${pathIcon}`; - const remoteEntries = Object.entries(settings.projectIconsByGitRemote); - return remoteEntries.length === 0 ? undefined : `remotes:${JSON.stringify(remoteEntries)}`; - }); + const configuredIconRevision = useEnvironmentSettings(input.environmentId, (settings) => + projectFaviconSettingsRevision(settings, input.cwd), + ); const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", cwd: input.cwd, diff --git a/apps/web/src/components/ProjectIconSettings.test.ts b/apps/web/src/components/ProjectIconSettings.test.ts index e30127e1c1f0..1dce9a747e4a 100644 --- a/apps/web/src/components/ProjectIconSettings.test.ts +++ b/apps/web/src/components/ProjectIconSettings.test.ts @@ -87,4 +87,64 @@ describe("replaceProjectIconSetting", () => { projectIconsByGitRemote: {}, }); }); + + it("removes the portable icon when switching back to workspace scope", () => { + expect( + replaceProjectIconSetting( + { + projectIcons: { + "/workspace/two": "/icons/two.svg", + }, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one-remote.svg", + "github.com/example/two": "/icons/two-remote.svg", + }, + }, + { + workspaceRoot: "/workspace/one", + repositoryKey: "github.com/example/one", + }, + "workspace", + "/icons/one-local.svg", + ), + ).toEqual({ + projectIcons: { + "/workspace/one": "/icons/one-local.svg", + "/workspace/two": "/icons/two.svg", + }, + projectIconsByGitRemote: { + "github.com/example/two": "/icons/two-remote.svg", + }, + }); + }); + + it("removes both scoped overrides when resetting from workspace scope", () => { + expect( + replaceProjectIconSetting( + { + projectIcons: { + "/workspace/one": "/icons/one-local.svg", + "/workspace/two": "/icons/two.svg", + }, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one-remote.svg", + "github.com/example/two": "/icons/two-remote.svg", + }, + }, + { + workspaceRoot: "/workspace/one", + repositoryKey: "github.com/example/one", + }, + "workspace", + " ", + ), + ).toEqual({ + projectIcons: { + "/workspace/two": "/icons/two.svg", + }, + projectIconsByGitRemote: { + "github.com/example/two": "/icons/two-remote.svg", + }, + }); + }); }); diff --git a/apps/web/src/components/ProjectIconSettings.tsx b/apps/web/src/components/ProjectIconSettings.tsx index 8f8e1a7253ce..8cc41e765628 100644 --- a/apps/web/src/components/ProjectIconSettings.tsx +++ b/apps/web/src/components/ProjectIconSettings.tsx @@ -4,6 +4,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { applyProjectIconUpdate } from "@t3tools/shared/serverSettings"; import { useCallback, useState } from "react"; import { useEnvironmentSettings } from "../hooks/useSettings"; @@ -34,21 +35,6 @@ export interface ProjectIconTarget { export type ProjectIconScope = "workspace" | "git-remote"; -function replaceIconInMap( - projectIcons: Readonly>, - key: string, - iconPath: string, -): Record { - const next = { ...projectIcons }; - const trimmedPath = iconPath.trim(); - if (trimmedPath.length === 0) { - delete next[key]; - } else { - next[key] = trimmedPath; - } - return next; -} - export function replaceProjectIconSetting( input: { readonly projectIcons: Readonly>; @@ -61,25 +47,23 @@ export function replaceProjectIconSetting( readonly projectIcons: Record; readonly projectIconsByGitRemote: Record; } { - if (scope === "git-remote" && target.repositoryKey) { - const projectIcons = { ...input.projectIcons }; - // A path-specific icon has higher precedence. Remove it when the user - // explicitly chooses the portable repository setting so the new value is - // immediately visible for this clone as well as other clones. - delete projectIcons[target.workspaceRoot]; - return { - projectIcons, - projectIconsByGitRemote: replaceIconInMap( - input.projectIconsByGitRemote, - target.repositoryKey, - iconPath, - ), - }; - } - return { - projectIcons: replaceIconInMap(input.projectIcons, target.workspaceRoot, iconPath), - projectIconsByGitRemote: { ...input.projectIconsByGitRemote }, - }; + const trimmedPath = iconPath.trim(); + return applyProjectIconUpdate( + input, + scope === "git-remote" && target.repositoryKey + ? { + scope, + workspaceRoot: target.workspaceRoot, + repositoryKey: target.repositoryKey, + iconPath: trimmedPath, + } + : { + scope: "workspace", + workspaceRoot: target.workspaceRoot, + ...(target.repositoryKey ? { repositoryKey: target.repositoryKey } : {}), + iconPath: trimmedPath, + }, + ); } function useProjectIconSetting(target: ProjectIconTarget) { @@ -107,24 +91,25 @@ function useProjectIconSetting(target: ProjectIconTarget) { const saveIconPath = useCallback( async (nextPath: string, scope: ProjectIconScope): Promise => { - const next = replaceProjectIconSetting( - { projectIcons, projectIconsByGitRemote }, - target, - scope, - nextPath, - ); - if ( - JSON.stringify(next.projectIcons) === JSON.stringify(projectIcons) && - JSON.stringify(next.projectIconsByGitRemote) === JSON.stringify(projectIconsByGitRemote) - ) { - return true; - } + const trimmedPath = nextPath.trim(); const result = await updateServerSettings({ environmentId: target.environmentId, input: { patch: { - projectIcons: next.projectIcons, - projectIconsByGitRemote: next.projectIconsByGitRemote, + projectIconUpdate: + scope === "git-remote" && target.repositoryKey + ? { + scope, + workspaceRoot: target.workspaceRoot, + repositoryKey: target.repositoryKey, + iconPath: trimmedPath, + } + : { + scope: "workspace", + workspaceRoot: target.workspaceRoot, + ...(target.repositoryKey ? { repositoryKey: target.repositoryKey } : {}), + iconPath: trimmedPath, + }, }, }, }); @@ -143,7 +128,7 @@ function useProjectIconSetting(target: ProjectIconTarget) { } return false; }, - [projectIcons, projectIconsByGitRemote, target, updateServerSettings], + [target, updateServerSettings], ); return { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 21d43bfccfb4..49539f7dedc3 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -205,6 +205,34 @@ describe("ServerSettings project icons", () => { }), ).toThrow(); }); + + it("decodes atomic project icon updates and permits a blank reset path", () => { + expect( + decodeServerSettingsPatch({ + projectIconUpdate: { + scope: "workspace", + workspaceRoot: " /workspace/t3code ", + repositoryKey: " github.com/t3tools/t3code ", + iconPath: " ", + }, + }).projectIconUpdate, + ).toEqual({ + scope: "workspace", + workspaceRoot: "/workspace/t3code", + repositoryKey: "github.com/t3tools/t3code", + iconPath: "", + }); + + expect(() => + decodeServerSettingsPatch({ + projectIconUpdate: { + scope: "git-remote", + workspaceRoot: "/workspace/t3code", + iconPath: "/icons/project.svg", + }, + }), + ).toThrow(); + }); }); describe("ServerSettingsPatch.providerInstances", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7c303cc769ff..9d9f1294e520 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -418,6 +418,7 @@ export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); const ProjectIconPath = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); const ProjectIconWorkspaceRoot = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); const ProjectIconGitRemote = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); +const ProjectIconUpdatePath = TrimmedString.check(Schema.isMaxLength(1024)); export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -570,6 +571,22 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +export const ProjectIconUpdate = Schema.Union([ + Schema.Struct({ + scope: Schema.Literal("workspace"), + workspaceRoot: ProjectIconWorkspaceRoot, + repositoryKey: Schema.optionalKey(ProjectIconGitRemote), + iconPath: ProjectIconUpdatePath, + }), + Schema.Struct({ + scope: Schema.Literal("git-remote"), + workspaceRoot: ProjectIconWorkspaceRoot, + repositoryKey: ProjectIconGitRemote, + iconPath: ProjectIconUpdatePath, + }), +]); +export type ProjectIconUpdate = typeof ProjectIconUpdate.Type; + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -583,6 +600,9 @@ export const ServerSettingsPatch = Schema.Struct({ // Whole-map replacement. Keys are normalized repository identities such as // github.com/t3tools/t3code, independent of clone URL or local path. projectIconsByGitRemote: Schema.optionalKey(Schema.Record(ProjectIconGitRemote, ProjectIconPath)), + // Atomic single-project update. The server applies this against the latest + // icon maps so concurrent edits cannot replace unrelated entries. + projectIconUpdate: Schema.optionalKey(ProjectIconUpdate), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( Schema.Struct({ diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index b95968eb87cd..a843b8a6e818 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -325,4 +325,90 @@ describe("serverSettings helpers", () => { "github.com/example/two": "/icons/two-next.svg", }); }); + + it("applies project icon updates without replacing unrelated concurrent entries", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + projectIcons: { + "/workspace/one": "/icons/one.svg", + "/workspace/concurrent": "/icons/concurrent.svg", + }, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one-remote.svg", + "github.com/example/concurrent": "/icons/concurrent-remote.svg", + }, + }; + + const next = applyServerSettingsPatch(current, { + projectIconUpdate: { + scope: "workspace", + workspaceRoot: "/workspace/one", + repositoryKey: "github.com/example/one", + iconPath: "/icons/one-next.svg", + }, + }); + + expect(next.projectIcons).toEqual({ + "/workspace/one": "/icons/one-next.svg", + "/workspace/concurrent": "/icons/concurrent.svg", + }); + expect(next.projectIconsByGitRemote).toEqual({ + "github.com/example/concurrent": "/icons/concurrent-remote.svg", + }); + expect("projectIconUpdate" in next).toBe(false); + }); + + it("removes the workspace override when switching to git-remote scope", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + projectIcons: { + "/workspace/one": "/icons/one.svg", + }, + projectIconsByGitRemote: { + "github.com/example/other": "/icons/other.svg", + }, + }; + + const next = applyServerSettingsPatch(current, { + projectIconUpdate: { + scope: "git-remote", + workspaceRoot: "/workspace/one", + repositoryKey: "github.com/example/one", + iconPath: "/icons/one-remote.svg", + }, + }); + + expect(next.projectIcons).toEqual({}); + expect(next.projectIconsByGitRemote).toEqual({ + "github.com/example/one": "/icons/one-remote.svg", + "github.com/example/other": "/icons/other.svg", + }); + }); + + it("clears both scopes when resetting a workspace icon", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + projectIcons: { + "/workspace/one": "/icons/one.svg", + }, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one-remote.svg", + "github.com/example/other": "/icons/other.svg", + }, + }; + + const next = applyServerSettingsPatch(current, { + projectIconUpdate: { + scope: "workspace", + workspaceRoot: "/workspace/one", + repositoryKey: "github.com/example/one", + iconPath: "", + }, + }); + + expect(next.projectIcons).toEqual({}); + expect(next.projectIconsByGitRemote).toEqual({ + "github.com/example/other": "/icons/other.svg", + }); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1007e535dbc7..d1ff2020db5c 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -57,6 +57,8 @@ export function resolveSourceControlWriterModelSelection( : settings.textGenerationModelSelection; } +type ProjectIconMaps = Pick; + export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; @@ -115,12 +117,58 @@ function mergeModelSelectionOptionsById(input: { return [...merged.entries()].map(([id, value]) => ({ id, value })); } +function replaceIconInMap( + entries: Readonly>, + key: string, + iconPath: string, +): Record { + const next = { ...entries }; + if (iconPath.length === 0) { + delete next[key]; + } else { + next[key] = iconPath; + } + return next; +} + +export function applyProjectIconUpdate( + current: ProjectIconMaps, + update: NonNullable, +): ProjectIconMaps { + if (update.scope === "git-remote") { + const projectIcons = { ...current.projectIcons }; + delete projectIcons[update.workspaceRoot]; + return { + projectIcons, + projectIconsByGitRemote: replaceIconInMap( + current.projectIconsByGitRemote, + update.repositoryKey, + update.iconPath, + ), + }; + } + + const projectIconsByGitRemote = { ...current.projectIconsByGitRemote }; + if (update.repositoryKey) { + delete projectIconsByGitRemote[update.repositoryKey]; + } + return { + projectIcons: replaceIconInMap(current.projectIcons, update.workspaceRoot, update.iconPath), + projectIconsByGitRemote, + }; +} + +/** + * Applies a server settings patch while treating textGenerationModelSelection as + * replace-on-provider/model updates. This prevents stale nested options from + * surviving a reset patch that intentionally omits options. + */ export function applyServerSettingsPatch( current: ServerSettings, patch: ServerSettingsPatch, ): ServerSettings { const selectionPatch = patch.textGenerationModelSelection; - const { automaticGitFetchInterval, ...patchForMerge } = patch; + const { automaticGitFetchInterval, projectIconUpdate, ...patchForMerge } = patch; const next = deepMerge(current, patchForMerge); const nextWithReplacements = { ...next, @@ -136,8 +184,14 @@ export function applyServerSettingsPatch( : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), }; + const nextWithProjectIconUpdate = projectIconUpdate + ? { + ...nextWithReplacements, + ...applyProjectIconUpdate(nextWithReplacements, projectIconUpdate), + } + : nextWithReplacements; if (!selectionPatch) { - return nextWithReplacements; + return nextWithProjectIconUpdate; } const instanceId = selectionPatch.instanceId ?? current.textGenerationModelSelection.instanceId; @@ -150,7 +204,7 @@ export function applyServerSettingsPatch( }); return { - ...nextWithReplacements, + ...nextWithProjectIconUpdate, textGenerationModelSelection: createModelSelection(instanceId, model, options), }; } From 16a0e5c87599fdbd04701f96cad0b1bc86384217 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 23:21:24 -0700 Subject: [PATCH 04/10] fix: close project icon cache and path gaps --- apps/server/src/assets/AssetAccess.test.ts | 55 +++++++++++++++++++ apps/server/src/assets/AssetAccess.ts | 49 ++++++++++++----- .../src/project/ProjectFaviconResolver.ts | 33 ++++++++--- apps/web/src/components/CommandPalette.tsx | 2 + .../web/src/components/ProjectFavicon.test.ts | 22 +++++++- apps/web/src/components/ProjectFavicon.tsx | 19 ++++--- .../src/components/ProjectIconSettings.tsx | 26 ++++++++- apps/web/src/components/Sidebar.tsx | 6 +- apps/web/src/components/SidebarV2.tsx | 3 + 9 files changed, 180 insertions(+), 35 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index e25fbf1731ac..0360f39d2d82 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -286,6 +286,60 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("finds workspace icon settings keyed by the unnormalized request root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-raw-root-", + }); + const requestedRoot = `${root}/.`; + const iconDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-raw-custom-", + }); + const iconPath = path.join(iconDirectory, "custom.svg"); + yield* fileSystem.writeFileString(iconPath, ""); + const settings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + projectIcons: { [requestedRoot]: iconPath }, + }), + updateSettings: () => Effect.die("not implemented"), + streamChanges: Stream.empty, + }); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: requestedRoot, revision: iconPath }, + }).pipe(Effect.provideService(ServerSettings.ServerSettingsService, settings)); + + expect(result.relativeUrl.endsWith("/custom.svg")).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects automatically discovered icon symlinks outside the workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-auto-symlink-root-", + }); + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-auto-symlink-outside-", + }); + const outsidePath = path.join(outsideDirectory, "secret.svg"); + yield* fileSystem.writeFileString(outsidePath, "secret"); + yield* fileSystem.symlink(outsidePath, path.join(root, "favicon.svg")); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe(Effect.flip); + + expect(error._tag).toBe("AssetProjectFaviconNotFoundError"); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects a configured project icon replaced by a symlink after signing", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -399,6 +453,7 @@ describe("AssetAccess", () => { cause: platformCause, }); const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolve: () => Effect.fail(resolutionCause), resolvePath: () => Effect.fail(resolutionCause), }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 1a84c9f9325b..166b88f3f2da 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -305,13 +305,14 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); const customIconPaths = [ - serverSettings.projectIcons[workspaceRoot], + serverSettings.projectIcons[workspaceRoot] ?? + serverSettings.projectIcons[input.resource.cwd], ...(repositoryIdentity ? [serverSettings.projectIconsByGitRemote[repositoryIdentity.canonicalKey]] : []), ].filter((iconPath): iconPath is string => iconPath !== undefined); - const faviconPath = yield* faviconResolver - .resolvePath(workspaceRoot, customIconPaths.length === 0 ? undefined : { customIconPaths }) + const resolvedFavicon = yield* faviconResolver + .resolve(workspaceRoot, customIconPaths.length === 0 ? undefined : { customIconPaths }) .pipe( Effect.mapError( (cause) => @@ -321,18 +322,34 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const canonicalFaviconPath = faviconPath - ? yield* optionOnNotFound(fileSystem.realPath(faviconPath)).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - ) + const canonicalFaviconPath = resolvedFavicon + ? resolvedFavicon.source === "custom-setting" + ? yield* optionOnNotFound(fileSystem.realPath(resolvedFavicon.path)).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ) + : yield* Effect.gen(function* () { + const canonicalPath = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot, + relativePath: path.relative(workspaceRoot, resolvedFavicon.path), + }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + return canonicalPath === null ? Option.none() : Option.some(canonicalPath); + }) : Option.none(); - if (faviconPath && Option.isNone(canonicalFaviconPath)) { + if (resolvedFavicon && Option.isNone(canonicalFaviconPath)) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource }); } claims = { @@ -341,7 +358,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i absolutePath: Option.getOrNull(canonicalFaviconPath), expiresAt, }; - fileName = faviconPath ? path.basename(faviconPath) : PROJECT_FAVICON_FALLBACK_MARKER; + fileName = resolvedFavicon + ? path.basename(resolvedFavicon.path) + : PROJECT_FAVICON_FALLBACK_MARKER; break; } } diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 566f7c0784a2..bb4369a7cde1 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -86,6 +86,22 @@ export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass }, + ) => Effect.Effect< + { + readonly path: string; + readonly source: "custom-setting" | "workspace"; + } | null, + ProjectFaviconResolutionError + >; /** * Resolve a favicon or icon file path for the provided workspace root. * @@ -179,8 +195,8 @@ export const make = Effect.gen(function* () { return null; }); - const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", + const resolve: ProjectFaviconResolver["Service"]["resolve"] = Effect.fn( + "ProjectFaviconResolver.resolve", )(function* (cwd, options) { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( @@ -212,7 +228,7 @@ export const make = Effect.gen(function* () { ), ); if (Option.isSome(customIconStats) && customIconStats.value.type === "File") { - return customIconPath; + return { path: customIconPath, source: "custom-setting" as const }; } } @@ -221,14 +237,14 @@ export const make = Effect.gen(function* () { if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); if (existing) { - return existing; + return { path: existing, source: "workspace" as const }; } } for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { - return existing; + return { path: existing, source: "workspace" as const }; } } @@ -272,14 +288,17 @@ export const make = Effect.gen(function* () { } const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); if (existing) { - return existing; + return { path: existing, source: "workspace" as const }; } } return null; }); - return ProjectFaviconResolver.of({ resolvePath }); + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = (cwd, options) => + resolve(cwd, options).pipe(Effect.map((resolved) => resolved?.path ?? null)); + + return ProjectFaviconResolver.of({ resolve, resolvePath }); }); export const layer = Layer.effect(ProjectFaviconResolver, make); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba60..433fa89d2a1d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -797,6 +797,7 @@ function OpenCommandPaletteDialog(props: { ), @@ -821,6 +822,7 @@ function OpenCommandPaletteDialog(props: { ), diff --git a/apps/web/src/components/ProjectFavicon.test.ts b/apps/web/src/components/ProjectFavicon.test.ts index fa24eaa7cbcd..d1511a1a544a 100644 --- a/apps/web/src/components/ProjectFavicon.test.ts +++ b/apps/web/src/components/ProjectFavicon.test.ts @@ -56,7 +56,7 @@ describe("projectFaviconSettingsRevision", () => { expect(changed).not.toBe(first); }); - it("uses only the higher-precedence workspace icon when configured", () => { + it("changes when remote settings change even with a workspace override", () => { const first = projectFaviconSettingsRevision( { projectIcons: { "/workspace/project": "/icons/local.svg" }, @@ -72,6 +72,24 @@ describe("projectFaviconSettingsRevision", () => { "/workspace/project", ); - expect(remoteChanged).toBe(first); + expect(remoteChanged).not.toBe(first); + }); + + it("changes when repository identity becomes available", () => { + const settings = { + projectIcons: {}, + projectIconsByGitRemote: { + "github.com/example/one": "/icons/one.svg", + }, + }; + + const unresolved = projectFaviconSettingsRevision(settings, "/workspace/project"); + const resolved = projectFaviconSettingsRevision( + settings, + "/workspace/project", + "github.com/example/one", + ); + + expect(resolved).not.toBe(unresolved); }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 7e7fd497fcb1..bbc1c08d400b 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -23,26 +23,31 @@ export function projectFaviconSettingsRevision( readonly projectIconsByGitRemote: Readonly>; }, cwd: string, + repositoryKey?: string | null, ): string | undefined { const pathIcon = settings.projectIcons[cwd]; const remoteEntries = Object.entries(settings.projectIconsByGitRemote); - if (!pathIcon && remoteEntries.length === 0) return undefined; - const revisionSource = JSON.stringify( - pathIcon - ? ["path", pathIcon] - : ["remotes", remoteEntries.sort(([left], [right]) => left.localeCompare(right))], - ); + if (!pathIcon && remoteEntries.length === 0 && !repositoryKey) return undefined; + const revisionSource = JSON.stringify([ + "path", + pathIcon ?? null, + "repository", + repositoryKey ?? null, + "remotes", + remoteEntries.sort(([left], [right]) => left.localeCompare(right)), + ]); return `icons:${revisionSource.length}:${hashProjectFaviconRevision(revisionSource)}`; } export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + repositoryKey?: string | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { const configuredIconRevision = useEnvironmentSettings(input.environmentId, (settings) => - projectFaviconSettingsRevision(settings, input.cwd), + projectFaviconSettingsRevision(settings, input.cwd, input.repositoryKey), ); const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", diff --git a/apps/web/src/components/ProjectIconSettings.tsx b/apps/web/src/components/ProjectIconSettings.tsx index 8cc41e765628..308fbf3e70b0 100644 --- a/apps/web/src/components/ProjectIconSettings.tsx +++ b/apps/web/src/components/ProjectIconSettings.tsx @@ -5,7 +5,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { applyProjectIconUpdate } from "@t3tools/shared/serverSettings"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useEnvironmentSettings } from "../hooks/useSettings"; import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; @@ -145,6 +145,25 @@ export function ProjectIconPathField({ target }: { readonly target: ProjectIconT const { iconPath, initialScope, saveIconPath, settingsPath } = useProjectIconSetting(target); const [draftPath, setDraftPath] = useState(iconPath); const [scope, setScope] = useState(initialScope); + const saveQueueRef = useRef(Promise.resolve()); + const queueSave = useCallback( + (nextPath: string, nextScope: ProjectIconScope) => { + const save = saveQueueRef.current.then(() => saveIconPath(nextPath, nextScope)); + saveQueueRef.current = save.then( + () => undefined, + () => undefined, + ); + return save; + }, + [saveIconPath], + ); + + useEffect(() => { + setDraftPath(iconPath); + }, [iconPath]); + useEffect(() => { + setScope(initialScope); + }, [initialScope]); return (