diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf5846782..df2addf71 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -74,6 +74,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+shift+k", command: "project.addByPath", when: "!terminalFocus" }, ]; function normalizeKeyToken(token: string): string { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..a724a5780 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -866,6 +866,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } + case WS_METHODS.filesystemBrowse: { + const body = stripRequestTag(request.body); + const expanded = path.resolve(yield* expandHomePath(body.partialPath)); + const endsWithSep = body.partialPath.endsWith("/") || body.partialPath === "~"; + const parentDir = endsWithSep ? expanded : path.dirname(expanded); + const prefix = endsWithSep ? "" : path.basename(expanded); + + const names = yield* fileSystem + .readDirectory(parentDir) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))); + + const showHidden = prefix.startsWith("."); + const filtered = names + .filter((n) => n.startsWith(prefix) && (showHidden || !n.startsWith("."))) + .slice(0, 100); + + const entries = yield* Effect.forEach( + filtered, + (name) => + fileSystem.stat(path.join(parentDir, name)).pipe( + Effect.map((s) => + s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null, + ), + Effect.catch(() => Effect.succeed(null)), + ), + { concurrency: 16 }, + ); + + return { + parentPath: parentDir, + entries: entries.filter(Boolean).slice(0, 50), + }; + } + case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; return { diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx new file mode 100644 index 000000000..23fa47728 --- /dev/null +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from "react"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { useQuery } from "@tanstack/react-query"; +import { FolderIcon } from "lucide-react"; +import { + Command, + CommandDialog, + CommandDialogPopup, + CommandFooter, + CommandInput, + CommandItem, + CommandList, + CommandPanel, +} from "~/components/ui/command"; +import { readNativeApi } from "../nativeApi"; + +interface BrowseEntry { + name: string; + fullPath: string; +} + +interface AddProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAddProject: (path: string) => void; +} + +export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjectDialogProps) { + const [inputValue, setInputValue] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const inputRef = useRef(null); + + const [debouncedPath] = useDebouncedValue(inputValue, { wait: 200 }); + + const { data: entries = [] } = useQuery({ + queryKey: ["filesystemBrowse", debouncedPath], + queryFn: async () => { + const api = readNativeApi(); + if (!api) return []; + const result = await api.projects.browseFilesystem({ partialPath: debouncedPath }); + return result.entries; + }, + enabled: open && debouncedPath.length > 0, + }); + + useEffect(() => { + setHighlightedIndex(0); + }, [entries]); + + const close = () => onOpenChange(false); + + const handleOpenChange = (next: boolean) => { + if (!next) { + setInputValue(""); + setHighlightedIndex(0); + } + onOpenChange(next); + }; + + const addProject = (path: string) => { + onAddProject(path); + close(); + }; + + const drillInto = (entry: BrowseEntry) => { + setInputValue(entry.fullPath + "/"); + inputRef.current?.focus(); + }; + + const getHighlightedEntry = () => + entries.length > 0 ? entries[Math.min(highlightedIndex, entries.length - 1)] : null; + + const handleKeyDown = (event: React.KeyboardEvent) => { + const highlighted = getHighlightedEntry(); + + switch (event.key) { + case "Tab": + event.preventDefault(); + if (highlighted) drillInto(highlighted); + break; + case "Enter": + event.preventDefault(); + if (highlighted) addProject(highlighted.fullPath); + else if (inputValue.trim()) addProject(inputValue.trim()); + break; + case "ArrowDown": + event.preventDefault(); + setHighlightedIndex((i) => Math.min(i + 1, entries.length - 1)); + break; + case "ArrowUp": + event.preventDefault(); + setHighlightedIndex((i) => Math.max(i - 1, 0)); + break; + case "Escape": + event.preventDefault(); + close(); + break; + } + }; + + return ( + + + + + } + value={inputValue} + onChange={(e) => setInputValue(e.currentTarget.value)} + onKeyDown={handleKeyDown} + /> + + {entries.map((entry, index) => ( + setHighlightedIndex(index)} + onClick={() => drillInto(entry)} + > + + {entry.name} + + {entry.fullPath} + + + ))} + + {inputValue.trim() && ( +
+ +
+ )} +
+ + Tab to autocomplete · Enter to add project + +
+
+
+ ); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 193cb0e7a..50bd622cc 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,11 +1,14 @@ -import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { AddProjectDialog } from "../components/AddProjectDialog"; import ThreadSidebar from "../components/Sidebar"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { newCommandId, newProjectId } from "../lib/utils"; +import { readNativeApi } from "../nativeApi"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -13,10 +16,11 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useAppSettings } from "~/appSettings"; +import { useStore } from "../store"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -function ChatRouteGlobalShortcuts() { +function ChatRouteGlobalShortcuts({ onOpenAddProject }: { onOpenAddProject: () => void }) { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = @@ -40,9 +44,6 @@ function ChatRouteGlobalShortcuts() { return; } - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), @@ -50,6 +51,16 @@ function ChatRouteGlobalShortcuts() { }, }); + if (command === "project.addByPath") { + event.preventDefault(); + event.stopPropagation(); + onOpenAddProject(); + return; + } + + const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + if (!projectId) return; + if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); @@ -81,6 +92,7 @@ function ChatRouteGlobalShortcuts() { clearSelection, handleNewThread, keybindings, + onOpenAddProject, projects, selectedThreadIdsSize, terminalOpen, @@ -92,6 +104,12 @@ function ChatRouteGlobalShortcuts() { function ChatRouteLayout() { const navigate = useNavigate(); + const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false); + const { handleNewThread } = useHandleNewThread(); + const projects = useStore((s) => s.projects); + const { settings: appSettings } = useAppSettings(); + + const openAddProjectDialog = () => setAddProjectDialogOpen(true); useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -109,9 +127,34 @@ function ChatRouteLayout() { }; }, [navigate]); + const handleAddProject = async (cwd: string) => { + const api = readNativeApi(); + if (!api) return; + + const existing = projects.find((p) => p.cwd === cwd); + if (existing) return; + + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + const segments = cwd.split(/[/\\]/).filter(Boolean); + const title = segments[segments.length - 1] ?? cwd; + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title, + workspaceRoot: cwd, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + createdAt, + }); + await handleNewThread(projectId, { + envMode: appSettings.defaultThreadEnvMode, + }).catch(() => undefined); + }; + return ( - + + void handleAddProject(path)} + /> ); } diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..a04922fad 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -114,6 +114,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + browseFilesystem: (input) => transport.request(WS_METHODS.filesystemBrowse, input), }, shell: { openInEditor: (cwd, editor) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..0c2c337ff 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -128,6 +128,10 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + browseFilesystem: (input: { partialPath: string }) => Promise<{ + parentPath: string; + entries: Array<{ name: string; fullPath: string }>; + }>; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b182..dd4fbd60a 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -16,6 +16,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + "project.addByPath", ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b..c30b6f6e5 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -72,6 +72,9 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Filesystem + filesystemBrowse: "filesystem.browse", + // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", @@ -136,6 +139,14 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), + // Filesystem + tagRequestBody( + WS_METHODS.filesystemBrowse, + Schema.Struct({ + partialPath: Schema.String, + }), + ), + // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule),