diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 559ccc90e0b..44d35607e10 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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' }) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 07ac0384e63..3f7b83080c8 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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, diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 48866637b0d..03abd7364dc 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -220,6 +220,7 @@ export const ar = defineLocale({ 'session.focusSearch': 'البحث في الجلسات', 'session.togglePin': 'تثبيت / إلغاء تثبيت الجلسة الحالية', 'workspace.newWorktree': 'worktree جديد', + 'workspace.openFolder': 'فتح مجلد كمشروع', 'composer.focus': 'التركيز على المحرّر', 'composer.modelPicker': 'فتح منتقي النموذج', 'composer.voice': 'بدء / إيقاف المحادثة الصوتية', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index f4a609c202f..392292bd7c2 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -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'] }, diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 8b0f9cbc99a..22e9ce2af81 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -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 { 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 { + 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 }) +}