feat(desktop): open a folder as a project with ⌘O

workspace.openFolder (default mod+o, the editor-standard open-folder chord)
runs openFolderAsProject: pick a folder, enter the project that already owns
it or create one named after the folder, scope the sidebar, and land on a
fresh session draft anchored there. A stale backend without the projects.*
RPC still gets the workspace session, with a warning.

StartWorkSessionRequest grows an openTab flag so these opens-from-nowhere
stack a tab instead of spending an occupied main, and goToProject/
resolveNewSessionCwd share one projectRootCwd resolver.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 01:15:01 -05:00
parent 8eb06e75b9
commit 5affcd6bf4
5 changed files with 92 additions and 6 deletions

View file

@ -528,7 +528,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
lastStartWorkTokenRef.current = startWorkSessionRequest.token
startSessionInWorkspace(startWorkSessionRequest.path)
startSessionInWorkspace(startWorkSessionRequest.path, { openTab: startWorkSessionRequest.openTab })
if (startWorkSessionRequest.draft) {
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })

View file

@ -33,7 +33,7 @@ import {
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { requestNewWorktree } from '@/store/projects'
import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { setModelPickerOpen } from '@/store/session'
import { reopenLastClosedTile } from '@/store/session-states'
@ -173,6 +173,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// Only meaningful inside a git repo — a no-op otherwise (the key falls
// through instead of silently doing nothing).
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
// ⌘O: native folder picker → open the folder as a project (upsert) with a
// fresh session anchored there.
'workspace.openFolder': () => void openFolderAsProject(),
// Narrow-viewport reveal is handled inside the store toggles now.
'view.toggleSidebar': toggleSidebarOpen,

View file

@ -220,6 +220,7 @@ export const ar = defineLocale({
'session.focusSearch': 'البحث في الجلسات',
'session.togglePin': 'تثبيت / إلغاء تثبيت الجلسة الحالية',
'workspace.newWorktree': 'worktree جديد',
'workspace.openFolder': 'فتح مجلد كمشروع',
'composer.focus': 'التركيز على المحرّر',
'composer.modelPicker': 'فتح منتقي النموذج',
'composer.voice': 'بدء / إيقاف المحادثة الصوتية',

View file

@ -88,6 +88,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'session.togglePin', category: 'session', defaults: [] },
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
// ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's
// workspace::Open). Picks a folder and opens it as a project (upsert:
// enters the owning project when one exists, else creates one), landing on
// a fresh session anchored there.
{ id: 'workspace.openFolder', category: 'session', defaults: ['mod+o'] },
// ── Navigation ───────────────────────────────────────────────────────────
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },

View file

@ -161,6 +161,36 @@ export function exitProjectScope(): void {
$projectScope.set(ALL_PROJECTS)
}
// A project's working root: its primary folder, else the first repo that has
// one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is
// the same rule over the same tree — this is the store-side copy so the store
// doesn't reach into the sidebar's React module.)
const projectRootCwd = (project: SidebarProjectTree | undefined): string =>
(project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
// ⌘K "go to project": flip the sidebar into grouped mode and enter the project
// — a pure scope switch, same as clicking the overview row (never spends main).
// With `newSession` (⌘-select / ⌘-Enter) it also lands on a fresh session draft
// anchored at the project root — stacked as a tab when main already holds a
// chat (palette opens are opens-from-nowhere). A path-less project (the Home
// bucket) gets a plain detached draft.
export function goToProject(id: string, options?: { newSession?: boolean }): void {
setSidebarAgentsGrouped(true)
enterProject(id)
if (!options?.newSession) {
return
}
const cwd = projectRootCwd($projectTree.get().find(node => node.id === id))
if (cwd) {
requestStartWorkSession(cwd, undefined, { openTab: true })
} else {
requestFreshSession()
}
}
// The cwd a NEW chat should start in. The "active project" is just an atom
// ($projectScope) — so when you're inside a project, a new session (cmd-n, the
// trunk "+") starts at that project's root (its primary repo = the default-branch
@ -177,8 +207,7 @@ export function resolveNewSessionCwd(): string {
}
if (scope !== ALL_PROJECTS) {
const project = $projectTree.get().find(node => node.id === scope)
const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
const cwd = projectRootCwd($projectTree.get().find(node => node.id === scope))
if (cwd) {
return cwd
@ -997,6 +1026,8 @@ export async function switchBranchInRepo(repoPath: string, branch: string): Prom
// effect even if the path repeats.
export interface StartWorkSessionRequest {
draft?: string
/** Stack the fresh session as a tab when main already holds a chat (palette/⌘O opens-from-nowhere). */
openTab?: boolean
path: string
token: number
}
@ -1016,7 +1047,7 @@ export function requestNewWorktree(): void {
let startWorkToken = 0
export function requestStartWorkSession(path: string, draft?: string): void {
export function requestStartWorkSession(path: string, draft?: string, options?: { openTab?: boolean }): void {
const target = path.trim()
if (!target) {
@ -1024,7 +1055,12 @@ export function requestStartWorkSession(path: string, draft?: string): void {
}
startWorkToken += 1
$startWorkSessionRequest.set({ draft: draft?.trim() || undefined, path: target, token: startWorkToken })
$startWorkSessionRequest.set({
draft: draft?.trim() || undefined,
openTab: options?.openTab || undefined,
path: target,
token: startWorkToken
})
}
export async function removeWorktreePath(
@ -1068,3 +1104,44 @@ export async function pickProjectFolder(): Promise<null | string> {
return dir || null
}
// ⌘O / palette "Open folder…": open a folder AS a project, upserting. A folder
// already covered by a project (explicit or auto) just enters it; anything else
// becomes a new project named after the folder. Either way the sidebar scopes
// to the project and a fresh session draft lands anchored at the folder — the
// one-keystroke version of new project → enter → new session. Like goToProject,
// this is an open-from-nowhere: an occupied main gets a stacked tab, not stolen.
export async function openFolderAsProject(dir?: string): Promise<void> {
const target = (dir ?? (await pickProjectFolder()) ?? '').trim()
if (!target) {
return
}
// Refresh first so the membership check runs against live truth — a repo
// cloned since the last scan should enter its auto project, not double-create.
await refreshProjectTree()
const existing = projectIdForCwd(target)
if (existing) {
setSidebarAgentsGrouped(true)
enterProject(existing)
} else {
const name = target.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || target
try {
const created = await createProject({ name, folders: [target], primaryPath: target, use: true })
if (created) {
enterProject(created.id)
}
} catch (err) {
// Stale backend (no projects.* RPC) or a failed write: still open the
// folder as a plain workspace session below — the project row can wait.
notify({ kind: 'warning', message: err instanceof Error ? err.message : String(err) })
}
}
requestStartWorkSession(target, undefined, { openTab: true })
}