Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ 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 {
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
149 changes: 149 additions & 0 deletions apps/web/src/components/AddProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(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 (
<CommandDialog open={open} onOpenChange={handleOpenChange}>
<CommandDialogPopup>
<Command>
<CommandPanel>
<CommandInput
ref={inputRef}
placeholder="Enter project path (e.g. ~/projects/my-app)"
startAddon={<FolderIcon />}
value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
<CommandList>
{entries.map((entry, index) => (
<CommandItem
key={entry.fullPath}
data-highlighted={index === highlightedIndex ? "" : undefined}
onPointerMove={() => setHighlightedIndex(index)}
onClick={() => drillInto(entry)}
>
<FolderIcon className="mr-2 size-4 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
<span className="ml-auto truncate text-xs text-muted-foreground">
{entry.fullPath}
</span>
</CommandItem>
))}
</CommandList>
{inputValue.trim() && (
<div className="border-t px-4 py-2">
<button
type="button"
className="w-full cursor-pointer rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => addProject(inputValue.trim())}
>
Add project at <span className="font-medium">{inputValue.trim()}</span>
</button>
</div>
)}
</CommandPanel>
<CommandFooter>
<span>Tab to autocomplete &middot; Enter to add project</span>
</CommandFooter>
</Command>
</CommandDialogPopup>
</CommandDialog>
);
}
62 changes: 55 additions & 7 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
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";
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 } =
Expand All @@ -40,16 +44,23 @@ function ChatRouteGlobalShortcuts() {
return;
}

const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;
if (!projectId) return;

const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
terminalOpen,
},
});

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();
Expand Down Expand Up @@ -81,6 +92,7 @@ function ChatRouteGlobalShortcuts() {
clearSelection,
handleNewThread,
keybindings,
onOpenAddProject,
projects,
selectedThreadIdsSize,
terminalOpen,
Expand All @@ -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;
Expand All @@ -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 (
<SidebarProvider defaultOpen>
<ChatRouteGlobalShortcuts />
<ChatRouteGlobalShortcuts onOpenAddProject={openAddProjectDialog} />
<Sidebar
side="left"
collapsible="offcanvas"
Expand All @@ -120,6 +163,11 @@ function ChatRouteLayout() {
<ThreadSidebar />
</Sidebar>
<Outlet />
<AddProjectDialog
open={addProjectDialogOpen}
onOpenChange={setAddProjectDialogOpen}
onAddProject={(path) => void handleAddProject(path)}
/>
</SidebarProvider>
);
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export interface NativeApi {
projects: {
searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
browseFilesystem: (input: { partialPath: string }) => Promise<{
parentPath: string;
entries: Array<{ name: string; fullPath: string }>;
}>;
};
shell: {
openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down