Browse Source

feat(desktop): add recently closed projects to home (#34926)

usrnk1 1 month ago
parent
commit
bf58fae51f

+ 11 - 1
packages/app/src/context/global.tsx

@@ -1,7 +1,8 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createEffect, createMemo, createRoot } from "solid-js"
 import { createStore } from "solid-js/store"
-import { createServerProjects, ServerConnection, useServer } from "./server"
+import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
+import { pathKey } from "@/utils/path-key"
 import { useServerHealth } from "@/utils/server-health"
 import { createServerSdkContext } from "./server-sdk"
 import { createServerSyncContext } from "./server-sync"
@@ -127,6 +128,14 @@ function createServerCtx(
   }
 
   const projectsList = createMemo(() => projects.list().map(enrich))
+  const recentlyClosedList = createMemo(() => {
+    const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
+    return projects
+      .recentlyClosed()
+      .filter((worktree) => known.has(pathKey(worktree)))
+      .slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
+      .map((worktree) => enrich({ worktree, expanded: false }))
+  })
 
   const isLocal =
     (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
@@ -139,6 +148,7 @@ function createServerCtx(
     projects: {
       ...projects,
       list: projectsList,
+      recentlyClosed: recentlyClosedList,
     },
   }
 }

+ 11 - 2
packages/app/src/context/layout.tsx

@@ -5,10 +5,11 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { useServerSync } from "./server-sync"
 import { useServerSDK } from "./server-sdk"
-import { ServerConnection, useServer } from "./server"
+import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
 import { usePlatform } from "./platform"
 import { Project } from "@opencode-ai/sdk/v2"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
+import { pathKey } from "@/utils/path-key"
 import { decode64 } from "@/utils/base64"
 import { same } from "@/utils/same"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -493,7 +494,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           const root = rootFor(project.worktree)
           if (root === project.worktree) continue
 
-          server.projects.close(project.worktree)
+          server.projects.remove(project.worktree)
 
           if (!seen.has(root)) {
             server.projects.open(root)
@@ -613,6 +614,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       },
       projects: {
         list,
+        recentlyClosed: createMemo(() => {
+          const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
+          return server.projects
+            .recentlyClosed()
+            .filter((worktree) => known.has(pathKey(worktree)))
+            .slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
+            .map((worktree) => enrich({ worktree, expanded: false }))
+        }),
         open(directory: string) {
           const root = rootFor(directory)
           if (server.projects.list().find((x) => x.worktree === root)) return

+ 83 - 1
packages/app/src/context/server.test.ts

@@ -99,7 +99,7 @@ describe("createServerProjects", () => {
   test("keeps active and explicit server buckets in one reactive store", () => {
     createRoot((dispose) => {
       const [scope] = createSignal(ServerScope.local)
-      const [store, setStore] = createStore({ projects: {}, lastProject: {} })
+      const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
       const active = createServerProjects({ scope, store, setStore })
       const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
 
@@ -115,6 +115,88 @@ describe("createServerProjects", () => {
       dispose()
     })
   })
+
+  test("tracks recently closed projects and drops them when reopened", () => {
+    createRoot((dispose) => {
+      const [scope] = createSignal(ServerScope.local)
+      const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
+      const projects = createServerProjects({ scope, store, setStore })
+
+      projects.open("/a")
+      projects.open("/b")
+      projects.close("/a")
+      expect(projects.recentlyClosed()).toEqual(["/a"])
+
+      projects.close("/b")
+      expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
+
+      projects.open("/a")
+      expect(projects.recentlyClosed()).toEqual(["/b"])
+      expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
+      dispose()
+    })
+  })
+
+  test("remove drops a project without recording it as recently closed", () => {
+    createRoot((dispose) => {
+      const [scope] = createSignal(ServerScope.local)
+      const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
+      const projects = createServerProjects({ scope, store, setStore })
+
+      projects.open("/repo/subdir")
+      projects.remove("/repo/subdir")
+      expect(projects.list()).toEqual([])
+      expect(projects.recentlyClosed()).toEqual([])
+      dispose()
+    })
+  })
+
+  test("retains recently closed history beyond the visible display limit", () => {
+    createRoot((dispose) => {
+      const [scope] = createSignal(ServerScope.local)
+      const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
+      const projects = createServerProjects({ scope, store, setStore })
+
+      // Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
+      // this prevents display-filtered entries from evicting still-visible ones.
+      for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
+        projects.open(dir)
+        projects.close(dir)
+      }
+      expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
+      dispose()
+    })
+  })
+
+  test("caps recently closed history at the store limit", () => {
+    createRoot((dispose) => {
+      const [scope] = createSignal(ServerScope.local)
+      const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
+      const projects = createServerProjects({ scope, store, setStore })
+
+      for (let i = 1; i <= 20; i++) {
+        projects.open(`/p${i}`)
+        projects.close(`/p${i}`)
+      }
+      expect(projects.recentlyClosed()).toHaveLength(16)
+      expect(projects.recentlyClosed()[0]).toBe("/p20")
+      expect(projects.recentlyClosed().at(-1)).toBe("/p5")
+      dispose()
+    })
+  })
+
+  test("dedupes recently closed entries by normalized path", () => {
+    createRoot((dispose) => {
+      const [scope] = createSignal(ServerScope.local)
+      const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
+      const projects = createServerProjects({ scope, store, setStore })
+
+      projects.close("/repo")
+      projects.close("/repo/")
+      expect(projects.recentlyClosed()).toEqual(["/repo/"])
+      dispose()
+    })
+  })
 })
 
 describe("migrateCanonicalLocalServerState", () => {

+ 40 - 5
packages/app/src/context/server.tsx

@@ -2,12 +2,23 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { type Accessor, batch, createMemo } from "solid-js"
 import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
+import { pathKey } from "@/utils/path-key"
 import { ServerScope } from "@/utils/server-scope"
 
 type StoredProject = { worktree: string; expanded: boolean }
 type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
-type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
+type ServerProjectState = {
+  projects: Record<string, StoredProject[]>
+  lastProject: Record<string, string>
+  recentlyClosed: Record<string, string[]>
+}
 const HEALTH_POLL_INTERVAL_MS = 10_000
+// The store retains more history than is displayed. Consumers filter recently closed entries
+// against the live project list (dropping deleted projects) and then cap the visible count via
+// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
+// filtered out do not evict still-visible ones from the persisted store.
+const RECENTLY_CLOSED_HISTORY_LIMIT = 16
+export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
 
 export function normalizeServerUrl(input: string) {
   const trimmed = input.trim()
@@ -72,19 +83,42 @@ export function createServerProjects<T extends ServerProjectState>(input: {
 }) {
   const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
   const current = () => input.store.projects[input.scope()] ?? []
+  const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
+  const remove = (directory: string) => {
+    setStore(
+      "projects",
+      input.scope(),
+      current().filter((project) => project.worktree !== directory),
+    )
+  }
   return {
     list: current,
+    recentlyClosed: currentClosed,
+    remove,
     open(directory: string) {
       const scope = input.scope()
+      const key = pathKey(directory)
+      const closed = currentClosed()
+      if (closed.some((worktree) => pathKey(worktree) === key)) {
+        setStore(
+          "recentlyClosed",
+          scope,
+          closed.filter((worktree) => pathKey(worktree) !== key),
+        )
+      }
       if (current().some((project) => project.worktree === directory)) return
       setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
     },
+    // User-initiated close: removes the project and records it in recently closed.
+    // Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
     close(directory: string) {
-      setStore(
-        "projects",
-        input.scope(),
-        current().filter((project) => project.worktree !== directory),
+      remove(directory)
+      const key = pathKey(directory)
+      const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
+        0,
+        RECENTLY_CLOSED_HISTORY_LIMIT,
       )
+      setStore("recentlyClosed", input.scope(), closed)
     },
     expand(directory: string) {
       const index = current().findIndex((project) => project.worktree === directory)
@@ -235,6 +269,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
         list: [] as StoredServer[],
         projects: {} as Record<string, StoredProject[]>,
         lastProject: {} as Record<string, string>,
+        recentlyClosed: {} as Record<string, string[]>,
       }),
     )
 

+ 1 - 0
packages/app/src/i18n/en.ts

@@ -604,6 +604,7 @@ export const dict = {
   "home.title": "Home",
   "home.projects": "Projects",
   "home.project.add": "Add project",
+  "home.recentlyClosed": "Recently closed",
   "home.server.collapse": "Collapse server projects",
   "home.server.expand": "Expand server projects",
   "home.sessions.search.placeholder": "Search sessions",

+ 105 - 6
packages/app/src/pages/home.tsx

@@ -270,6 +270,10 @@ export function NewHome() {
   })
   const focusedSync = () => focusedServerCtx()?.sync ?? sync()
   const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
+  const recentlyClosed = createMemo(
+    () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
+  )
+  const homedir = createMemo(() => focusedSync().data.path.home ?? "")
   const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
   const newSessionProject = createMemo(
     () =>
@@ -518,10 +522,13 @@ export function NewHome() {
       <div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
         <HomeProjectColumn
           projects={projects()}
+          recentlyClosed={recentlyClosed()}
+          homedir={homedir()}
           selected={selection()}
           focusServer={focusServer}
           selectProject={selectProject}
           openNewSession={openProjectNewSession}
+          openRecentProject={(conn, directory) => addProjects(conn, [directory])}
           chooseProject={(conn) => void chooseProject(conn)}
           editProject={editProject}
           closeProject={(conn, directory) => {
@@ -638,10 +645,13 @@ export function NewHome() {
 
 function HomeProjectColumn(props: {
   projects: LocalProject[]
+  recentlyClosed: LocalProject[]
+  homedir: string
   selected: HomeProjectSelection
   focusServer: (server: ServerConnection.Any) => void
   selectProject: (server: ServerConnection.Any, directory: string) => void
   openNewSession: (server: ServerConnection.Any, directory: string) => void
+  openRecentProject: (server: ServerConnection.Any, directory: string) => void
   chooseProject: (server: ServerConnection.Any) => void
   editProject: (server: ServerConnection.Any, project: LocalProject) => void
   closeProject: (server: ServerConnection.Any, directory: string) => void
@@ -670,8 +680,10 @@ function HomeProjectColumn(props: {
       aria-label={props.language.t("home.projects")}
     >
       <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
-        <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
-        <Show when={global.servers.list().length === 1}>
+        <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
+        <Show
+          when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
+        >
           <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
             <IconButtonV2
               data-action="home-add-project"
@@ -691,7 +703,21 @@ function HomeProjectColumn(props: {
           when={global.servers.list().length > 1}
           fallback={
             <div class="pr-3">
-              <HomeProjectList {...props} server={global.servers.list()[0]!} />
+              <Show
+                when={props.projects.length > 0}
+                fallback={
+                  <HomeProjectEmpty
+                    server={global.servers.list()[0]!}
+                    recentlyClosed={props.recentlyClosed}
+                    homedir={props.homedir}
+                    chooseProject={props.chooseProject}
+                    openRecentProject={props.openRecentProject}
+                    language={props.language}
+                  />
+                }
+              >
+                <HomeProjectList {...props} server={global.servers.list()[0]!} />
+              </Show>
             </div>
           }
         >
@@ -897,6 +923,79 @@ function HomeProjectList(props: {
   )
 }
 
+function HomeProjectEmpty(props: {
+  server: ServerConnection.Any
+  recentlyClosed: LocalProject[]
+  homedir: string
+  chooseProject: (server: ServerConnection.Any) => void
+  openRecentProject: (server: ServerConnection.Any, directory: string) => void
+  language: ReturnType<typeof useLanguage>
+}) {
+  const global = useGlobal()
+  const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
+  return (
+    <div class="flex min-w-0 flex-col gap-1">
+      <button
+        type="button"
+        data-action="home-add-project-row"
+        class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+        disabled={unreachable()}
+        onClick={() => props.chooseProject(props.server)}
+      >
+        <IconV2 name="folder-add-left" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
+      </button>
+      <Show when={props.recentlyClosed.length > 0}>
+        <div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
+          <div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
+        </div>
+        <For each={props.recentlyClosed}>
+          {(project) => (
+            <HomeRecentlyClosedRow
+              project={project}
+              server={props.server}
+              homedir={props.homedir}
+              openRecentProject={props.openRecentProject}
+              language={props.language}
+            />
+          )}
+        </For>
+      </Show>
+    </div>
+  )
+}
+
+function HomeRecentlyClosedRow(props: {
+  project: LocalProject
+  server: ServerConnection.Any
+  homedir: string
+  openRecentProject: (server: ServerConnection.Any, directory: string) => void
+  language: ReturnType<typeof useLanguage>
+}) {
+  const global = useGlobal()
+  const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
+  const path = () => {
+    const home = props.homedir
+    const worktree = props.project.worktree
+    if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
+    return worktree
+  }
+  return (
+    <TooltipV2 placement="right" value={path()}>
+      <button
+        type="button"
+        data-component="home-recently-closed-row"
+        class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
+        disabled={unreachable()}
+        onClick={() => props.openRecentProject(props.server, props.project.worktree)}
+      >
+        <HomeProjectAvatar project={props.project} outline />
+        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
+      </button>
+    </TooltipV2>
+  )
+}
+
 function HomeProjectRow(props: {
   project: LocalProject
   server: ServerConnection.Any
@@ -979,13 +1078,13 @@ function HomeProjectRow(props: {
   )
 }
 
-function HomeProjectAvatar(props: { project: LocalProject }) {
+function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
   const name = createMemo(() => displayName(props.project))
   return (
     <ProjectAvatar
       fallback={name()}
-      src={getProjectAvatarSource(props.project.id, props.project.icon)}
-      variant={getProjectAvatarVariant(props.project.icon?.color)}
+      src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
+      variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
     />
   )
 }

+ 7 - 0
packages/ui/src/v2/components/project-avatar-v2.css

@@ -81,6 +81,13 @@
   --project-avatar-border: var(--v2-avatar-border-gray);
 }
 
+[data-slot="project-avatar-surface"][data-variant="outline"] {
+  --project-avatar-bg: var(--v2-background-bg-base);
+  --project-avatar-border: var(--v2-border-border-strong);
+  color: var(--v2-text-text-muted);
+  text-shadow: none;
+}
+
 [data-slot="project-avatar-surface"][data-has-image] {
   background: var(--project-avatar-bg);
 }

+ 9 - 1
packages/ui/src/v2/components/project-avatar-v2.stories.tsx

@@ -11,6 +11,7 @@ Saturated 16px project avatar with color variants and optional unread dot.
 
 ### Variants
 - Color: orange, yellow, cyan, green, red, pink, blue, purple, gray.
+- Outline: neutral, muted style for de-emphasized projects (e.g. recently closed).
 - Image vs initial content state.
 - Unread dot with corner mask when \`unread\` is set.
 
@@ -33,7 +34,7 @@ export default {
   argTypes: {
     variant: {
       control: "select",
-      options: [...PROJECT_AVATAR_VARIANTS],
+      options: [...PROJECT_AVATAR_VARIANTS, "outline"],
     },
   },
   args: {
@@ -62,6 +63,13 @@ export const AllVariants = {
   ),
 }
 
+export const Outline = {
+  args: {
+    fallback: "O",
+    variant: "outline",
+  },
+}
+
 export const Unread = {
   args: {
     fallback: "O",

+ 4 - 1
packages/ui/src/v2/components/project-avatar-v2.tsx

@@ -26,10 +26,13 @@ export const PROJECT_AVATAR_VARIANTS = [
 
 export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number]
 
+// "outline" is a neutral, muted style (e.g. recently closed projects) and is not part of the color rotation.
+export type ProjectAvatarStyle = ProjectAvatarVariant | "outline"
+
 export interface ProjectAvatarProps extends ComponentProps<"div"> {
   fallback: string
   src?: string
-  variant?: ProjectAvatarVariant
+  variant?: ProjectAvatarStyle
   unread?: boolean
 }