Compare commits

...

8 Commits

Author SHA1 Message Date
Aiden Cline 02e2ac0499 fix(desktop): group recent projects by server 2026-08-17 10:22:48 +00:00
Aiden Cline 2fac40101d fix(desktop): use native recent projects menu 2026-08-17 10:13:37 +00:00
Aiden Cline e392a7d3c0 feat(desktop): open recent projects from file menu 2026-08-17 10:00:13 +00:00
Aiden Cline 72f9ee4ce3 fix(app): collapse home add action 2026-08-17 09:42:11 +00:00
Aiden Cline 690f93e6a4 feat(app): dismiss recent home projects 2026-08-17 09:37:39 +00:00
Aiden Cline 5e9ab77efb fix(app): show added home projects 2026-08-17 09:36:03 +00:00
Aiden Cline d6b2e154ef fix(app): keep recent project picker open 2026-08-17 09:32:01 +00:00
Aiden Cline 5deb0b4fc8 fix(app): suggest server projects on home 2026-08-17 09:20:05 +00:00
20 changed files with 373 additions and 51 deletions
+27 -1
View File
@@ -8,7 +8,7 @@ import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import {
type Component,
@@ -37,6 +37,8 @@ import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import { desktopRecentProjectCommand } from "@/desktop-menu"
import { displayName } from "@/pages/layout/helpers"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
@@ -182,6 +184,8 @@ function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const navigate = useNavigate()
command.register("desktop", () => {
const commands: CommandOption[] = []
@@ -195,6 +199,28 @@ function DesktopCommands() {
},
})
}
global.servers.list().forEach((server) => {
const ctx = global.ensureServerCtx(server)
ctx.projects.recent().forEach((project) => {
commands.push({
id: desktopRecentProjectCommand(ServerConnection.key(server), project.worktree),
title: displayName(project),
category: language.t("command.category.file"),
hidden: true,
onSelect: () => {
const location = { directory: project.worktree }
void ctx.sdk.api.file
.list({ path: ".", location })
.then(() => ctx.sdk.api.project.current({ location }))
.then((value) => ctx.sync.child(project.worktree, { bootstrap: false })[1]("project", value.id))
.catch(() => undefined)
ctx.projects.open(project.worktree)
ctx.projects.touch(project.worktree)
navigate("/")
},
})
})
})
return commands
})
@@ -6,9 +6,18 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import {
DESKTOP_MENU,
desktopMenuVisible,
desktopRecentProjectCommand,
type DesktopMenuAction,
type DesktopMenuEntry,
} from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/servers"
import { displayName } from "@/pages/layout/helpers"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
@@ -17,6 +26,7 @@ export function WindowsAppMenu(props: {
}) {
let lastFocused: HTMLElement | undefined
const language = useLanguage()
const global = useGlobal()
const rememberFocus = () => {
const active = document.activeElement
@@ -90,6 +100,49 @@ export function WindowsAppMenu(props: {
{(entry) => {
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
if (entry.type === "separator") return <DropdownMenu.Separator />
if (entry.dynamic === "recentProjects") {
const servers = global.servers.list()
const groups = servers
.map((server) => ({
server,
projects: global.ensureServerCtx(server).projects.recent().slice(0, 5),
}))
.filter((group) => group.projects.length > 0)
return (
<DesktopMenuSubmenu label={entry.labelKey ? language.t(entry.labelKey) : ""}>
<For each={groups}>
{(group, index) => (
<>
<Show when={index() > 0}>
<DropdownMenu.Separator />
</Show>
<Show when={servers.length > 1}>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">
{serverName(group.server)}
</DropdownMenu.GroupLabel>
</Show>
<For each={group.projects}>
{(project) => (
<DesktopMenuItem
label={displayName(project)}
disabled={false}
onSelect={() =>
runCommand(
desktopRecentProjectCommand(
ServerConnection.key(group.server),
project.worktree,
),
)
}
/>
)}
</For>
</>
)}
</For>
</DesktopMenuSubmenu>
)
}
return (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
+14
View File
@@ -3,6 +3,7 @@ import { Accessor, createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
import { pathKey } from "@/utils/path-key"
import { knownProjectWorktrees } from "./project-suggestions"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
@@ -129,6 +130,17 @@ function createServerController(
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
})
const knownProjectsList = createMemo(() => {
return knownProjectWorktrees({
open: projects.list().map((project) => project.worktree),
recentlyClosed: projects.recentlyClosed(),
known: sync.data.project.map((project) => project.worktree),
limit: RECENTLY_CLOSED_DISPLAY_LIMIT,
}).map((worktree) => enrich({ worktree, expanded: false }))
})
const recentProjectsList = createMemo(() =>
[...recentlyClosedList(), ...knownProjectsList()].slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT),
)
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
@@ -141,6 +153,8 @@ function createServerController(
...projects,
list: projectsList,
recentlyClosed: recentlyClosedList,
known: knownProjectsList,
recent: recentProjectsList,
},
permission,
notification,
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import { knownProjectWorktrees } from "./project-suggestions"
test("suggests known projects that are neither open nor recently closed", () => {
expect(
knownProjectWorktrees({
open: ["/code/open"],
recentlyClosed: ["/code/recent"],
known: ["/code/open", "/code/recent", "/code/known", "/code/other"],
limit: 5,
}),
).toEqual(["/code/known", "/code/other"])
})
test("deduplicates paths using platform path semantics and caps suggestions", () => {
expect(
knownProjectWorktrees({
open: ["/code/open/"],
recentlyClosed: [],
known: ["/code/open", "/code/one", "/code/two"],
limit: 1,
}),
).toEqual(["/code/one"])
})
@@ -0,0 +1,11 @@
import { pathKey } from "@/utils/path-key"
export function knownProjectWorktrees(input: {
open: string[]
recentlyClosed: string[]
known: string[]
limit: number
}) {
const hidden = new Set([...input.open, ...input.recentlyClosed].map(pathKey))
return input.known.filter((worktree) => !hidden.has(pathKey(worktree))).slice(0, input.limit)
}
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
import { DESKTOP_MENU, desktopRecentProjectCommand } from "./desktop-menu"
describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => {
@@ -20,4 +20,19 @@ describe("desktop menu", () => {
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
expect(roleItems.length).toBeGreaterThan(0)
})
test("places recent projects directly below open project", () => {
const file = DESKTOP_MENU.find((menu) => menu.id === "file")
const open = file?.items?.findIndex((item) => item.type === "item" && item.command === "project.open") ?? -1
const recent = file?.items?.findIndex((item) => item.type === "item" && item.dynamic === "recentProjects") ?? -1
expect(open).toBeGreaterThanOrEqual(0)
expect(recent).toBe(open + 1)
})
test("creates distinct recent project commands", () => {
expect(desktopRecentProjectCommand("server:a", "/code/one")).not.toBe(
desktopRecentProjectCommand("server:a", "/code/two"),
)
})
})
+16
View File
@@ -48,6 +48,7 @@ export type DesktopMenuItem = {
type: "item"
labelKey?: DesktopNativeKey
command?: string
dynamic?: "recentProjects"
action?: DesktopMenuAction
role?: DesktopMenuRole
href?: string
@@ -112,6 +113,11 @@ export const DESKTOP_MENU: DesktopMenu[] = [
command: "project.open",
accelerator: { macos: "Cmd+O" },
},
{
type: "item",
labelKey: "desktop.menu.openRecentProjects",
dynamic: "recentProjects",
},
{
type: "item",
labelKey: "desktop.menu.settings",
@@ -297,6 +303,16 @@ export const DESKTOP_MENU: DesktopMenu[] = [
},
]
export type DesktopRecentProject = {
command: string
label: string
server?: string
}
export function desktopRecentProjectCommand(server: string, directory: string) {
return `project.openRecent:${encodeURIComponent(server)}:${encodeURIComponent(directory)}`
}
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
return !item.platforms || item.platforms.includes(platform)
}
+1
View File
@@ -242,6 +242,7 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.exportLogs": "Export Logs...",
"desktop.menu.newSession": "New Session",
"desktop.menu.openProject": "Open Project...",
"desktop.menu.openRecentProjects": "Open Recent Projects",
"desktop.menu.newWindow": "New Window",
"desktop.menu.closeWindow": "Close Window",
"desktop.menu.undo": "Undo",
+1 -1
View File
@@ -637,7 +637,7 @@ export const dict = {
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.recentlyClosed": "Recently closed",
"home.recentlyClosed": "Recent projects",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions",
+1
View File
@@ -1,5 +1,6 @@
export { AppBaseProviders, AppInterface } from "./app"
export { useLayout } from "./context/layout"
export { useGlobal } from "./context/global"
export { useServerSDK } from "./context/server-sdk"
export { useServerSync } from "./context/server-sync"
export { useServers as useServers } from "./context/servers"
@@ -18,8 +18,6 @@ export function createHomeController() {
const focusedServerCtx = useServerCtx(focusedServer)
const focusedSync = () => focusedServerCtx()?.sync
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? [])
const recentlyClosed = createMemo(() => focusedServerCtx()?.projects.recentlyClosed() ?? [])
const homedir = createMemo(() => focusedSync()?.data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
@@ -62,11 +60,11 @@ export function createHomeController() {
},
project: {
list: projects,
recentlyClosed,
homedir,
selected: selectedProject,
newSession: newSessionProject,
forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(),
recentForServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.recent(),
homedirForServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).sync.data.path.home,
select: (conn: ServerConnection.Any, directory: string) => {
const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
@@ -68,8 +68,8 @@ export function createHomeProjectsController(home: HomeController) {
},
project: {
list: home.project.list,
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
recentForServer: home.project.recentForServer,
homedirForServer: home.project.homedirForServer,
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
@@ -31,11 +31,13 @@ export type HomeProjectsViewProps = {
language: ReturnType<typeof useLanguage>
servers: ServerConnection.Any[]
projects: LocalProject[]
recentlyClosed: LocalProject[]
selection: HomeProjectSelection
homedir: string
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
recentForServer: (server: ServerConnection.Any) => LocalProject[]
showRecentForServer: (server: ServerConnection.Any) => boolean
onDismissRecent: (server: ServerConnection.Any) => void
homedirForServer: (server: ServerConnection.Any) => string
collapsed: (server: ServerConnection.Any) => boolean
canDefaultServer: boolean
defaultServerKey: ServerConnection.Key | null | undefined
@@ -81,7 +83,16 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Show
when={
props.servers.length === 1 &&
!(
props.projects.length === 0 &&
props.showRecentForServer(props.servers[0]) &&
props.recentForServer(props.servers[0]).length > 0
)
}
>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-project"
@@ -102,8 +113,16 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
fallback={
<div class="pr-3">
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
when={!props.showRecentForServer(props.servers[0]) && props.projects.length > 0}
fallback={
<HomeProjectEmpty
{...props}
{...contextMenuProps}
server={props.servers[0]}
projects={props.projects}
items={props.showRecentForServer(props.servers[0]) ? props.recentForServer(props.servers[0]) : []}
/>
}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
</Show>
@@ -114,8 +133,11 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
<For each={props.servers}>
{(item) => {
const projects = () => props.projectsForServer(item)
const recent = () => props.recentForServer(item)
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const showRecent = () => props.showRecentForServer(item)
const hasChildren = () => hasProjects() || (showRecent() && recent().length > 0)
const collapsed = () => props.collapsed(item)
return (
<div class="flex min-w-0 flex-col gap-1">
@@ -125,11 +147,25 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
{...contextMenuProps}
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
collapsed={collapsed()}
hasChildren={hasChildren()}
health={props.serverHealth(item)}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<Show when={healthy() && hasChildren() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
<Show
when={!showRecent() && hasProjects()}
fallback={
<HomeProjectEmpty
{...props}
{...contextMenuProps}
server={item}
projects={projects()}
items={showRecent() ? recent() : []}
/>
}
>
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
</Show>
</Show>
</div>
)
@@ -193,10 +229,11 @@ function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
collapsed: boolean
hasChildren: boolean
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const canToggle = () => healthy() && props.hasChildren
const contextMenuID = () => serverContextMenuID(props.server)
onCleanup(() => {
const id = contextMenuID()
@@ -379,37 +416,55 @@ function HomeProjectSlot(
}
function HomeProjectEmpty(
props: HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
},
props: HomeProjectsViewProps &
HomeProjectsContextMenuProps & {
server: ServerConnection.Any
projects: LocalProject[]
items: LocalProject[]
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
disabled={unreachable()}
onClick={() => props.onChooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
<Show when={props.projects.length > 0}>
<HomeProjectList {...props} server={props.server} items={props.projects} />
</Show>
<Show when={props.projects.length === 0}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
disabled={unreachable()}
onClick={() => props.onChooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
<Show when={props.items.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<div class="group/recent relative mt-3 flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-1">
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
<TooltipV2 placement="bottom" value={props.language.t("common.dismiss")}>
<IconButtonV2
data-action="home-dismiss-recent-projects"
class="opacity-0 group-hover/recent:opacity-100 focus-visible:opacity-100"
variant="ghost-muted"
size="small"
icon={<IconV2 name="close" />}
aria-label={props.language.t("common.dismiss")}
onClick={() => props.onDismissRecent(props.server)}
/>
</TooltipV2>
</div>
<For each={props.items}>
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
{(project) => <HomeSuggestedProjectRow {...props} project={project} server={props.server} />}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(
function HomeSuggestedProjectRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
@@ -417,24 +472,44 @@ function HomeRecentlyClosedRow(
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
const path = () => {
const home = props.homedir
const home = props.homedirForServer(props.server)
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()}>
<HomeProjectNavButton
type="button"
data-component="home-recently-closed-row"
class="disabled:opacity-60"
disabled={unreachable()}
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<TooltipV2 class="w-full" placement="right" value={path()}>
<HomeProjectNavButton
type="button"
data-component="home-recent-project-row"
class="pr-10 disabled:opacity-60"
disabled={unreachable()}
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</HomeProjectNavButton>
</TooltipV2>
<div
class={`
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2
group-hover/project:opacity-100 focus-within:opacity-100
`}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</HomeProjectNavButton>
</TooltipV2>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-recent-project"
variant="ghost-muted"
size="small"
icon={<IconV2 name="plus" />}
aria-label={props.language.t("home.project.add")}
disabled={unreachable()}
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
/>
</TooltipV2>
</div>
</div>
)
}
+17 -2
View File
@@ -1,18 +1,33 @@
import type { HomeProjectsController } from "./home-projects-controller"
import { HomeProjectsView } from "./home-projects-view"
import type { HomeScrollController } from "./home-scroll-controller"
import { ServerConnection } from "@/context/servers"
import { createStore } from "solid-js/store"
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
const recentMode = new Map<ServerConnection.Key, boolean>()
const [dismissedRecent, setDismissedRecent] = createStore({} as Record<string, boolean>)
const showRecentForServer = (server: ServerConnection.Any) => {
const key = ServerConnection.key(server)
if (dismissedRecent[key]) return false
if (recentMode.get(key)) return true
if (props.projects.server.projects(server).length > 0) return false
recentMode.set(key, true)
return true
}
return (
<HomeProjectsView
language={props.projects.copy.language}
servers={props.projects.server.list()}
projects={props.projects.project.list()}
recentlyClosed={props.projects.project.recentlyClosed()}
selection={props.projects.selection.value()}
homedir={props.projects.project.homedir()}
serverHealth={props.projects.server.health}
projectsForServer={props.projects.server.projects}
recentForServer={props.projects.project.recentForServer}
showRecentForServer={showRecentForServer}
onDismissRecent={(server) => setDismissedRecent(ServerConnection.key(server), true)}
homedirForServer={props.projects.project.homedirForServer}
collapsed={props.projects.server.collapsed}
canDefaultServer={props.projects.server.canDefault()}
defaultServerKey={props.projects.server.defaultKey()}
+11
View File
@@ -9,6 +9,7 @@ import { app, BrowserWindow } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu"
import type { DesktopRecentProject } from "@opencode-ai/app/desktop-menu"
import type { ServerReadyData } from "../preload/types"
import { checkAppExists, resolveAppPath } from "./apps"
@@ -236,6 +237,7 @@ const main = Effect.gen(function* () {
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(() => stopWslServers())
const recentProjects = new Map<number, DesktopRecentProject[]>()
const menuDeps = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
@@ -243,6 +245,10 @@ const main = Effect.gen(function* () {
},
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch,
recentProjects: () => {
const win = getLastFocusedWindow()
return win ? (recentProjects.get(win.webContents.id) ?? []) : []
},
}
registerIpcHandlers({
killSidecar: () => undefined,
@@ -273,12 +279,17 @@ const main = Effect.gen(function* () {
setNativeTranslations: (bundle) => {
if (setNativeTranslations(bundle)) createMenu(menuDeps)
},
setRecentProjects: (webContentsID, projects) => {
recentProjects.set(webContentsID, projects)
createMenu(menuDeps)
},
})
registerUpdaterIpc(updater)
void updater.start()
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
updateTimer.unref()
app.once("will-quit", () => clearInterval(updateTimer))
app.on("browser-window-focus", () => createMenu(menuDeps))
yield* Effect.promise(() => startNetLog()).pipe(
Effect.catch((error) =>
Effect.sync(() => {
+16 -1
View File
@@ -3,7 +3,7 @@ import { stat } from "node:fs/promises"
import { basename, join } from "node:path"
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopMenuAction, DesktopRecentProject } from "@opencode-ai/app/desktop-menu"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
@@ -49,6 +49,7 @@ type Deps = {
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
setRecentProjects: (webContentsID: number, projects: DesktopRecentProject[]) => void
}
export function registerIpcHandlers(deps: Deps) {
@@ -92,6 +93,20 @@ export function registerIpcHandlers(deps: Deps) {
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
})
ipcMain.handle("set-recent-projects", (event: IpcMainInvokeEvent, value: unknown) => {
if (event.senderFrame !== event.sender.mainFrame) throw new Error("Invalid recent projects sender")
if (!Array.isArray(value)) throw new Error("Invalid recent projects")
const projects = value.filter((item): item is DesktopRecentProject => {
if (!item || typeof item !== "object") return false
const project = item as Record<string, unknown>
return (
typeof project.command === "string" &&
typeof project.label === "string" &&
(project.server === undefined || typeof project.server === "string")
)
})
deps.setRecentProjects(event.sender.id, projects.slice(0, 50))
})
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
try {
const store = getStore(name)
+26
View File
@@ -4,6 +4,7 @@ import {
DESKTOP_MENU,
desktopMenuVisible,
type DesktopMenuEntry,
type DesktopRecentProject,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
@@ -16,6 +17,7 @@ type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
relaunch: () => void
recentProjects: () => DesktopRecentProject[]
}
export function createMenu(deps: Deps) {
@@ -44,6 +46,30 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
}
if (entry.dynamic === "recentProjects") {
const projects = deps.recentProjects()
const servers = new Map<string, DesktopRecentProject[]>()
projects.forEach((project) => {
if (!project.server) return
servers.set(project.server, [...(servers.get(project.server) ?? []), project])
})
item.submenu = servers.size
? [...servers.entries()].flatMap(([server, entries], index) => [
...(index > 0 ? ([{ type: "separator" as const }] satisfies MenuItemConstructorOptions[]) : []),
{ label: server, enabled: false },
...entries.slice(0, 5).map((project) => ({
label: project.label,
click: () => deps.trigger(project.command),
})),
])
: projects.slice(0, 5).map((project) => ({
label: project.label,
click: () => deps.trigger(project.command),
}))
item.enabled = item.submenu.length > 0
return item
}
if (entry.command) {
const command = entry.command
item.click = () => deps.trigger(command)
+1
View File
@@ -84,6 +84,7 @@ const api: ElectronAPI = {
ipcRenderer.on("menu-command", handler)
return () => ipcRenderer.removeListener("menu-command", handler)
},
setRecentProjects: (projects) => ipcRenderer.invoke("set-recent-projects", projects),
onDeepLink: (cb) => {
const handler = (_: unknown, urls: string[]) => cb(urls)
ipcRenderer.on("deep-link", handler)
+2 -1
View File
@@ -1,4 +1,4 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopMenuAction, DesktopRecentProject } from "@opencode-ai/app/desktop-menu"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
@@ -71,6 +71,7 @@ export type ElectronAPI = {
getWindowID: () => Promise<string>
onMenuCommand: (cb: (id: string) => void) => () => void
setRecentProjects: (projects: DesktopRecentProject[]) => Promise<void>
onDeepLink: (cb: (urls: string[]) => void) => () => void
openDirectoryPicker: (opts?: {
+19
View File
@@ -15,7 +15,9 @@ import {
useCommand,
useWslServers,
useLanguage,
useGlobal,
} from "@opencode-ai/app"
import { desktopRecentProjectCommand } from "@opencode-ai/app/desktop-menu"
import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
@@ -360,6 +362,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
function DesktopEffects() {
const cmd = useCommand()
const global = useGlobal()
menuTrigger = (id) => cmd.trigger(id)
const theme = useTheme()
@@ -373,6 +376,22 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
}
})
createEffect(() => {
const servers = global.servers.list()
const multiple = servers.length > 1
const projects = servers.flatMap((server) =>
global
.ensureServerCtx(server)
.projects.recent()
.map((project) => ({
command: desktopRecentProjectCommand(ServerConnection.key(server), project.worktree),
label: project.name ?? project.worktree.split(/[\\/]/).filter(Boolean).at(-1) ?? project.worktree,
server: multiple ? (server.displayName ?? new URL(server.http.url).host) : undefined,
})),
)
void window.api.setRecentProjects(projects)
})
return null
}