From 5f085e579ffe5b10e76f6d8d42607b81c1add304 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Wed, 12 Aug 2026 23:01:30 +0200 Subject: [PATCH 1/8] feat(daemon): add file.search RPC for symbol lookup Add Method::FileSearch with SymbolMatch results and a case-insensitive grep search over the task working tree, skipping binary files and heavy vendor/cache directories. Used as backing for go-to-definition in the editor. --- crates/warpforge-protocol/src/lib.rs | 27 ++++++ src/daemon/actor.rs | 40 +++++++++ src/daemon/diff.rs | 125 +++++++++++++++++++++++++++ src/daemon/server.rs | 12 +++ 4 files changed, 204 insertions(+) diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs index 8cf68b5..5747814 100644 --- a/crates/warpforge-protocol/src/lib.rs +++ b/crates/warpforge-protocol/src/lib.rs @@ -27,6 +27,10 @@ fn default_true() -> bool { true } +fn default_search_limit() -> u32 { + 200 +} + fn default_terminal_cols() -> u16 { 80 } @@ -386,6 +390,18 @@ pub enum Method { }, #[serde(rename = "file.delete")] FileDelete { task_id: String, path: String }, + /// Plain-text search across the task's project working tree (grep). Powers + /// "go to definition" (a symbol under the cursor resolved to its definition + /// lines) and quick symbol lookup, without needing a full LSP server. + #[serde(rename = "file.search")] + FileSearch { + task_id: String, + /// Case-insensitive substring matched against each line. + query: String, + /// Cap on the number of matches returned (cheap safety valve). + #[serde(default = "default_search_limit")] + limit: u32, + }, /// Stage files and commit them in the task's repo. `files=None` stages all /// changes; `amend` rewrites the previous commit. #[serde(rename = "git.commit")] @@ -1325,6 +1341,17 @@ pub struct ProjectFile { pub changed: bool, } +/// One line-level match from `file.search` — a project path plus 1-based line and +/// column where `query` appears, with the matching source line for context. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SymbolMatch { + pub path: String, + pub line: u32, + pub column: u32, + pub text: String, +} + // ─── Terminal agents (legacy PTY path) ─────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/daemon/actor.rs b/src/daemon/actor.rs index 74c0ba5..673289b 100644 --- a/src/daemon/actor.rs +++ b/src/daemon/actor.rs @@ -756,6 +756,13 @@ pub enum Command { path: String, reply: oneshot::Sender>, }, + /// Plain-text search across the task's project working tree. + SearchFiles { + task_id: String, + query: String, + limit: u32, + reply: oneshot::Sender>, + }, /// Accept (keep) or reject (revert) a single hunk in the working tree. ResolveHunk { task_id: String, @@ -1274,6 +1281,23 @@ impl DaemonHandle { rx.await.unwrap_or_default() } + pub async fn search_files( + &self, + task_id: &str, + query: &str, + limit: u32, + ) -> Vec { + let (tx, rx) = oneshot::channel(); + self.send(Command::SearchFiles { + task_id: task_id.to_string(), + query: query.to_string(), + limit, + reply: tx, + }) + .await; + rx.await.unwrap_or_default() + } + pub async fn git_commit( &self, task_id: &str, @@ -2865,6 +2889,22 @@ impl Daemon { }; let _ = reply.send(files); } + Command::SearchFiles { + task_id, + query, + limit, + reply, + } => { + let repo = self + .tasks + .get(&task_id) + .and_then(|t| self.project_path(&t.project)); + let matches = match repo { + Some(p) => super::diff::search_files(&p, &query, limit).unwrap_or_default(), + None => Vec::new(), + }; + let _ = reply.send(matches); + } Command::SaveFile { task_id, path, diff --git a/src/daemon/diff.rs b/src/daemon/diff.rs index 8eac5ba..8c73b67 100644 --- a/src/daemon/diff.rs +++ b/src/daemon/diff.rs @@ -72,6 +72,95 @@ pub async fn list_files(repo: &str, include_ignored: bool) -> Result Result> { + if query.trim().is_empty() { + return Ok(Vec::new()); + } + let needle = query.to_lowercase(); + let mut out = Vec::new(); + search_walk( + std::path::Path::new(repo), + std::path::Path::new(repo), + &needle, + limit, + &mut out, + )?; + Ok(out) +} + +fn search_walk( + root: &std::path::Path, + dir: &std::path::Path, + needle: &str, + limit: u32, + out: &mut Vec, +) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + if out.len() as u32 >= limit { + break; + } + let entry = entry?; + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if HEAVY_DIRS.contains(&name.as_ref()) { + continue; + } + if name.ends_with(".swp") || name.ends_with("~") { + continue; + } + if path.is_dir() { + search_walk(root, &path, needle, limit, out)?; + } else if path.is_file() { + if let Ok(rel) = path.strip_prefix(root) { + scan_file( + &rel.to_string_lossy().replace('\\', "/"), + &path, + needle, + limit, + out, + )?; + if out.len() as u32 >= limit { + break; + } + } + } + } + Ok(()) +} + +fn scan_file( + rel: &str, + path: &std::path::Path, + needle: &str, + limit: u32, + out: &mut Vec, +) -> Result<()> { + // Skip obvious binaries cheaply (~\0 in the first chunk). + let cheap = std::fs::read(path)?; + if cheap.iter().take(8000).any(|&b| b == 0) { + return Ok(()); + } + let text = String::from_utf8_lossy(&cheap); + for (line, l) in (1u32..).zip(text.split('\n')) { + if out.len() as u32 >= limit { + break; + } + if let Some(col) = l.to_lowercase().find(needle) { + out.push(wire::SymbolMatch { + path: rel.to_string(), + line, + column: col as u32 + 1, + text: l.to_string(), + }); + } + } + Ok(()) +} + fn is_ignored_path(path: &str) -> bool { if path.split('/').any(|part| HEAVY_DIRS.contains(&part)) { return true; @@ -1485,6 +1574,42 @@ mod tests { git(dir, &["config", "user.name", "t"]).await; } + #[test] + fn search_finds_substring_with_line_and_column() { + let dir = std::env::temp_dir().join(format!("wf-search-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.txt"), "hello world\nfn helper() {\n}\nfoo\n").unwrap(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write( + dir.join("sub").join("b.txt"), + "line with helper here\nanother\n", + ) + .unwrap(); + // A heavy dir must be skipped. + std::fs::create_dir_all(dir.join("node_modules")).unwrap(); + std::fs::write(dir.join("node_modules").join("x.txt"), "helper\n").unwrap(); + + let matches = search_files(dir.to_str().unwrap(), "helper", 50).unwrap(); + + // node_modules excluded; only two real hits across two files. + assert_eq!(matches.len(), 2); + let a = matches.iter().find(|m| m.path == "a.txt").unwrap(); + assert_eq!((a.line, a.column), (2, 4)); + let b = matches.iter().find(|m| m.path == "sub/b.txt").unwrap(); + assert_eq!((b.line, b.column), (1, 11)); + + // Case-insensitive. + let upper = search_files(dir.to_str().unwrap(), "Helper", 50).unwrap(); + assert_eq!(upper.len(), 2); + + // Empty query yields nothing. + assert!(search_files(dir.to_str().unwrap(), "", 50) + .unwrap() + .is_empty()); + + std::fs::remove_dir_all(&dir).ok(); + } + #[tokio::test] async fn switch_branch_carries_dirty_changes() { let dir = std::env::temp_dir().join(format!("wf-sw-{}", uuid::Uuid::new_v4())); diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 70d9adf..86cedec 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -638,6 +638,17 @@ async fn dispatch( message, }) } + FileSearch { + task_id, + query, + limit, + } => { + let matches = handle.search_files(&task_id, &query, limit).await; + serde_json::to_value(matches).map_err(|e| wire::RpcError { + code: wire::ErrorCode::Internal, + message: e.to_string(), + }) + } GitCommit { task_id, message, @@ -1334,6 +1345,7 @@ fn method_is_mutation(method: &wire::Method) -> bool { | DiffGet { .. } | FileContents { .. } | FileList { .. } + | FileSearch { .. } | GitBranches { .. } | GitPushInfo { .. } | OrchestrateList {} From 1217d4c46c67eb4ffee4dad7f9bbdb96223b6a70 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Wed, 12 Aug 2026 23:01:33 +0200 Subject: [PATCH 2/8] feat(desktop): add double-Shift quick-open file palette Triggered by double-Shift (600ms window) or Cmd/Ctrl+P. Ranks and filters the project file list and opens the selected file in a tab. --- desktop/src/App.tsx | 40 ++++++ desktop/src/components/QuickOpen.test.tsx | 85 ++++++++++++ desktop/src/components/QuickOpen.tsx | 154 ++++++++++++++++++++++ desktop/src/hooks/useQuickOpenShortcut.ts | 45 +++++++ desktop/src/query.ts | 17 ++- 5 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 desktop/src/components/QuickOpen.test.tsx create mode 100644 desktop/src/components/QuickOpen.tsx create mode 100644 desktop/src/hooks/useQuickOpenShortcut.ts diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 2d4744c..84b041a 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,3 +1,4 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { toast } from "sonner"; @@ -5,6 +6,7 @@ import AppHeader from "@/components/AppHeader"; import AttentionToast from "@/components/AttentionToast"; import BootstrapWizard from "@/components/BootstrapWizard"; import ErrorBoundary from "@/components/ErrorBoundary"; +import { QuickOpen } from "@/components/QuickOpen"; import Sidebar from "@/components/Sidebar"; import { TooltipProvider } from "@/components/ui/tooltip"; import { daemon } from "@/daemon"; @@ -17,7 +19,9 @@ import { useFontScaling } from "./hooks/useFontScaling"; import { useTheme } from "./hooks/useTheme"; import { usePullShortcut } from "./hooks/usePullShortcut"; import { usePushShortcut } from "./hooks/usePushShortcut"; +import { useQuickOpenShortcut } from "./hooks/useQuickOpenShortcut"; import { useTauriClose } from "./hooks/useTauriClose"; +import { queryClient, useProjectFileListQuery } from "./query"; import AddProjectDialog from "./views/AddProjectDialog"; import AgentSetupDialog from "./views/AgentSetupDialog"; import MissionControl from "./views/MissionControl"; @@ -43,6 +47,36 @@ function LiveSidebar(props: Omit, "state">) return ; } +/** Hosts the quick-open palette: owns the file-list query and the double-Shift + * trigger. Rendered as a child of the QueryClientProvider so its hook sees the + * client (App's own hooks must not query — they'd render before the provider). */ +function QuickOpenHost({ + openTaskId, + hasOpenTask, +}: { + openTaskId: string | null; + hasOpenTask: boolean; +}) { + const [open, setOpen] = useState(false); + const filesQuery = useProjectFileListQuery(hasOpenTask ? openTaskId : null); + const openTaskThroughNav = useUi((s) => s.openTaskWithNav); + useQuickOpenShortcut(() => { + if (hasOpenTask) setOpen(true); + }); + return ( + { + if (openTaskId) openTaskThroughNav(openTaskId, { surface: "files", path }); + }} + onClose={() => setOpen(false)} + /> + ); +} + const getSnapshot = () => daemon.getState().snapshot; const getConnection = () => daemon.getState().connection; const getConnectionError = () => daemon.getState().connectionError; @@ -232,6 +266,7 @@ export default function App() { const persistentWidth = sidebarCollapsed ? SIDEBAR_COLLAPSED_WIDTH : sidebarWidth; return ( + {/* Prototype shell: full-height sidebar beside a column of topbar + content. */}
@@ -300,6 +335,10 @@ export default function App() {
{pushOpen && } + {addProjectOpen && ( )} @@ -353,5 +392,6 @@ export default function App() { )}
+
); } diff --git a/desktop/src/components/QuickOpen.test.tsx b/desktop/src/components/QuickOpen.test.tsx new file mode 100644 index 0000000..5e87289 --- /dev/null +++ b/desktop/src/components/QuickOpen.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { QuickOpen } from "./QuickOpen"; + +import type { ProjectFile } from "../protocol"; + +const files: ProjectFile[] = [ + { path: "src/components/CodeEditor.tsx", changed: true }, + { path: "src/daemon.ts", changed: false }, + { path: "src/lib/codemirrorTheme.ts", changed: false }, + { path: "package.json", changed: false }, + { path: "crates/warpforge-protocol/src/lib.rs", changed: false }, +]; + +function setup(props: Partial> = {}) { + const onPick = vi.fn<(path: string) => void>(); + const onClose = vi.fn<() => void>(); + render( + , + ); + return { onPick, onClose }; +} + +describe("QuickOpen", () => { + it("renders all files with an empty query", () => { + setup(); + expect(screen.getByPlaceholderText("Jump to file…")).toBeInTheDocument(); + expect(screen.getByText("src/daemon.ts")).toBeInTheDocument(); + }); + + it("renders nothing when closed", () => { + setup({ open: false }); + expect(screen.queryByPlaceholderText("Jump to file…")).not.toBeInTheDocument(); + }); + + it("filters by query using the composer ranker", () => { + setup(); + fireEvent.change(screen.getByPlaceholderText("Jump to file…"), { + target: { value: "daemon" }, + }); + expect(screen.getByText("src/daemon.ts")).toBeInTheDocument(); + expect(screen.queryByText("package.json")).not.toBeInTheDocument(); + }); + + it("picks the active file on Enter", () => { + const { onPick, onClose } = setup(); + fireEvent.change(screen.getByPlaceholderText("Jump to file…"), { + target: { value: "code" }, + }); + const input = screen.getByPlaceholderText("Jump to file…"); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onPick).toHaveBeenCalledWith("src/components/CodeEditor.tsx"); + expect(onClose).toHaveBeenCalled(); + }); + + it("navigates with arrow keys before picking", () => { + const { onPick } = setup(); + const input = screen.getByPlaceholderText("Jump to file…") as HTMLInputElement; + fireEvent.change(input, { target: { value: ".ts" } }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + // The second ranked ".ts" file (after CodeEditor) is daemon.ts (not rs/json). + expect(onPick).toHaveBeenCalledWith("src/daemon.ts"); + }); + + it("closes on Escape", () => { + const { onClose } = setup(); + fireEvent.keyDown(screen.getByPlaceholderText("Jump to file…"), { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows a loading state", () => { + setup({ loading: true, files: [] }); + expect(screen.getByText("Loading files…")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/desktop/src/components/QuickOpen.tsx b/desktop/src/components/QuickOpen.tsx new file mode 100644 index 0000000..c315411 --- /dev/null +++ b/desktop/src/components/QuickOpen.tsx @@ -0,0 +1,154 @@ +import { FilePlus2, FileText, Loader2, Search } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { getFileIconUrl } from "@/lib/fileIcon"; +import { rankFiles } from "@/lib/composerMentions"; +import { cn } from "@/lib/utils"; + +import type { ProjectFile } from "../protocol"; + +/** + * Quick-open palette — the "double ‹⇧› Shift" file switcher. Filters the + * task's project files by the typed query (reusing the composer's file@ ranker) + * and opens the pick on Enter. Remains local: no global store, driven entirely + * by props from its host. + */ +export function QuickOpen({ + open, + files, + loading, + error, + onPick, + onClose, +}: { + open: boolean; + files: ProjectFile[]; + loading: boolean; + error: string | null; + onPick: (path: string) => void; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + if (!open) return; + setQuery(""); + setActiveIndex(0); + requestAnimationFrame(() => inputRef.current?.focus()); + }, [open]); + + const matches = useMemo(() => { + if (query.trim() === "") return files.slice(0, 200); + return rankFiles(files, query.trim()).slice(0, 200); + }, [files, query]); + + useEffect(() => { + setActiveIndex(0); + }, [query]); + + if (!open) return null; + + const choose = (path: string) => { + onPick(path); + onClose(); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + const count = matches.length; + if (event.key === "ArrowDown" && count) { + event.preventDefault(); + setActiveIndex((i) => (i + 1) % count); + return; + } + if (event.key === "ArrowUp" && count) { + event.preventDefault(); + setActiveIndex((i) => (i - 1 + count) % count); + return; + } + if (event.key === "Enter" && count) { + event.preventDefault(); + choose(matches[Math.min(activeIndex, count - 1)].path); + return; + } + }; + + useEffect(() => { + const el = listRef.current; + if (!el) return; + const active = el.querySelector("[data-active='true']"); + if (active && typeof active.scrollIntoView === "function") { + active.scrollIntoView({ block: "nearest" }); + } + }, [activeIndex]); + + return ( +
+
+
+ + setQuery(event.target.value)} + onKeyDown={onKeyDown} + placeholder="Jump to file…" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + className="h-11 min-w-0 flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none" + /> +
+
+ {loading && ( +
+ + Loading files… +
+ )} + {error &&

{error}

} + {!loading && !error && matches.length === 0 && ( +

No matching files

+ )} + {matches.map((file, index) => { + const iconUrl = getFileIconUrl(file.path); + return ( + + ); + })} +
+
+
+ ); +} \ No newline at end of file diff --git a/desktop/src/hooks/useQuickOpenShortcut.ts b/desktop/src/hooks/useQuickOpenShortcut.ts new file mode 100644 index 0000000..2d42fad --- /dev/null +++ b/desktop/src/hooks/useQuickOpenShortcut.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef } from "react"; + +const DOUBLE_SHIFT_WINDOW_MS = 600; + +/** + * Invokes `onOpen` on a double-Shift press (the WebStorm/IntelliJ "search + * everywhere" gesture) or on ⌘/Ctrl+P. Shift is also the selection modifier, + * so a double-press must land within a short window while a normal Shift-tap + * holds no state — the keyboard keeps "running" only while Shift is held down. + */ +export function useQuickOpenShortcut(onOpen: () => void) { + const onOpenRef = useRef(onOpen); + const lastShiftAtRef = useRef(0); + + useEffect(() => { + onOpenRef.current = onOpen; + }, [onOpen]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Shift") { + const now = Date.now(); + if (now - lastShiftAtRef.current < DOUBLE_SHIFT_WINDOW_MS) { + event.preventDefault(); + onOpenRef.current(); + lastShiftAtRef.current = 0; + } else { + lastShiftAtRef.current = now; + } + return; + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "p") { + event.preventDefault(); + onOpenRef.current(); + return; + } + // Any other key resets the pending first Shift so typing never misfires. + if (event.key !== "Shift") { + lastShiftAtRef.current = 0; + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); +} \ No newline at end of file diff --git a/desktop/src/query.ts b/desktop/src/query.ts index 8201338..7413933 100644 --- a/desktop/src/query.ts +++ b/desktop/src/query.ts @@ -1,6 +1,7 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, useQuery } from "@tanstack/react-query"; import { daemon } from "./daemon"; +import type { ProjectFile } from "./protocol"; /** * TanStack Query is used ONLY for on-demand daemon *reads* — diff, file @@ -30,3 +31,17 @@ export const daemonQuery = (method: string, params?: unknown) => () => daemon.request(method, params) as Promise; + +/** Project file list for a task, shared with the editor tree's query key so + * the quick-open palette and FilesSurface stay in the same cache. */ +export function useProjectFileListQuery(taskId: string | null, includeIgnored = true) { + return useQuery({ + enabled: Boolean(taskId), + placeholderData: (prev: ProjectFile[] | undefined) => prev, + queryFn: daemonQuery("file.list", { + include_ignored: includeIgnored, + task_id: taskId, + }), + queryKey: ["fileList", taskId ?? "", includeIgnored ? "all" : "tracked"], + }); +} From c494119a46251cbd3b294abf4a86d7d5a9cd9d19 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Wed, 12 Aug 2026 23:01:35 +0200 Subject: [PATCH 3/8] feat(desktop): go-to-definition via Cmd/Ctrl-click or CmdB Wire the daemon file.search RPC through the editor popup to jump to a symbol's file, line and column. --- .changeset/tall-pillows-search.md | 5 + desktop/src/components/CodeEditor.tsx | 115 +++++++++++++++++- desktop/src/daemon.ts | 2 + desktop/src/protocol.ts | 8 ++ desktop/src/views/TaskDetail.tsx | 13 ++ .../src/views/task-detail/FilesSurface.tsx | 8 +- 6 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 .changeset/tall-pillows-search.md diff --git a/.changeset/tall-pillows-search.md b/.changeset/tall-pillows-search.md new file mode 100644 index 0000000..5379da2 --- /dev/null +++ b/.changeset/tall-pillows-search.md @@ -0,0 +1,5 @@ +--- +"warpforge-desktop": patch +--- + +Add double-Shift quick-open file palette, go-to-definition (Cmd/Ctrl-click or CmdB), and a daemon `file.search` RPC backing symbol lookup. \ No newline at end of file diff --git a/desktop/src/components/CodeEditor.tsx b/desktop/src/components/CodeEditor.tsx index 00fe94c..05cecac 100644 --- a/desktop/src/components/CodeEditor.tsx +++ b/desktop/src/components/CodeEditor.tsx @@ -3,14 +3,14 @@ import { EditorState } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import { basicSetup } from "codemirror"; import { Check, Code, Eye, Save } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { codemirrorLanguageForPath } from "@/lib/codemirrorLanguages"; import { cmChromeForMode } from "@/lib/codemirrorTheme"; import { cn } from "@/lib/utils"; import { useThemeMode } from "@/hooks/useTheme"; -import type { FileDoc } from "../protocol"; +import type { FileDoc, SymbolMatch } from "../protocol"; import { Markdown } from "./Markdown"; type SaveStatus = "clean" | "unsaved" | "saved"; @@ -38,16 +38,27 @@ export function CodeEditor({ doc, editable, onSave, + onGotoDefinition, + onOpenSymbol, }: { doc: FileDoc; editable: boolean; onSave: (content: string) => void; + /** Resolve a symbol under the cursor to project lines (go-to-definition). + * When provided, ⌘/Ctrl-click and ⌘B run it. */ + onGotoDefinition?: (query: string) => Promise; + /** Open a found symbol's file at its line/column. */ + onOpenSymbol?: (path: string, line: number, column: number) => void; }) { const host = useRef(null); const viewRef = useRef(null); const onSaveRef = useRef(onSave); + const onGotoRef = useRef(onGotoDefinition); + const onOpenSymbolRef = useRef(onOpenSymbol); const [status, setStatus] = useState("clean"); const [preview, setPreview] = useState(false); + const [gotoResults, setGotoResults] = useState([]); + const [gotoActive, setGotoActive] = useState(0); const markdown = isMarkdownPath(doc.path); const svgImage = isSvgPath(doc.path); const binaryImage = isBinaryImagePath(doc.path); @@ -59,6 +70,10 @@ export function CodeEditor({ useEffect(() => { onSaveRef.current = onSave; }, [onSave]); + useEffect(() => { + onGotoRef.current = onGotoDefinition; + onOpenSymbolRef.current = onOpenSymbol; + }, [onGotoDefinition, onOpenSymbol]); const flushSave = () => { const view = viewRef.current; @@ -71,6 +86,45 @@ export function CodeEditor({ return true; }; + const runGoto = useCallback((): boolean => { + const view = viewRef.current; + const save = onGotoRef.current; + if (!view || !save) { + return false; + } + const head = view.state.selection.main.head; + const word = view.state.wordAt(head); + if (!word) { + return false; + } + const query = view.state.sliceDoc(word.from, word.to).trim(); + if (!query) { + return false; + } + setGotoResults([]); + void save(query).then((results) => { + if (!results.length) { + return; + } + setGotoResults(results.slice(0, 12)); + setGotoActive(0); + }); + return true; + }, []); + + const pickGoto = useCallback( + (index: number) => { + const hit = gotoResults[index]; + if (!hit) { + return; + } + const open = onOpenSymbolRef.current; + setGotoResults([]); + open?.(hit.path, hit.line, hit.column); + }, + [gotoResults], + ); + useEffect(() => { const parent = host.current; if (!parent || binaryImage) { @@ -92,7 +146,31 @@ export function CodeEditor({ EditorView.lineWrapping, ...language, EditorState.readOnly.of(!editable || isReadOnly), - keymap.of([{ key: "Mod-s", run: flushSave }]), +keymap.of([ + { key: "Mod-s", run: flushSave }, + ...(onGotoDefinition + ? [{ key: "Mod-b", run: runGoto, preventDefault: true }] + : []), + ]), + ...(onGotoDefinition + ? [ + EditorView.domEventHandlers({ + mousedown(event, cv) { + if (!(event.metaKey || event.ctrlKey) || event.button !== 0) { + return false; + } + event.preventDefault(); + const pos = cv.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos === null) { + return false; + } + cv.dispatch({ selection: { anchor: pos } }); + runGoto(); + return true; + }, + }), + ] + : []), EditorView.updateListener.of((u) => { if (!u.docChanged) { return; @@ -206,6 +284,37 @@ export function CodeEditor({ )} style={{ fontSize: "var(--app-mono-font-size)" }} /> + {gotoResults.length > 0 && !showPreview && ( +
+
+ Go to definition +
+
+ {gotoResults.map((hit, index) => ( + + ))} +
+
+ )} {showPreview && (
{svgImage ? ( diff --git a/desktop/src/daemon.ts b/desktop/src/daemon.ts index 667487a..a72c105 100644 --- a/desktop/src/daemon.ts +++ b/desktop/src/daemon.ts @@ -447,6 +447,8 @@ export class DaemonClient { const files = diff.files.map((f) => ({ changed: true, path: f.path })); return Promise.resolve(files); } + case "file.search": + return Promise.resolve([]); case "file.save": return Promise.resolve({}); case "git.pushInfo": { diff --git a/desktop/src/protocol.ts b/desktop/src/protocol.ts index ec3e055..df7dd92 100644 --- a/desktop/src/protocol.ts +++ b/desktop/src/protocol.ts @@ -458,6 +458,14 @@ export interface ProjectFile { changed: boolean; } +/** One line-level match from `file.search` — path plus 1-based line/column. */ +export interface SymbolMatch { + path: string; + line: number; + column: number; + text: string; +} + // ── Git ops (update / branch switch) ──────────────────────────────────────── export type GitOpStatus = "up_to_date" | "ok" | "conflict" | "error"; diff --git a/desktop/src/views/TaskDetail.tsx b/desktop/src/views/TaskDetail.tsx index 5abbe50..76981ad 100644 --- a/desktop/src/views/TaskDetail.tsx +++ b/desktop/src/views/TaskDetail.tsx @@ -173,6 +173,17 @@ export default function TaskDetail({ task, snapshot, onOpenTask, onOpenPush }: P }, [setActiveSurface, setShowDiff], ); + const searchSymbol = useCallback( + (query: string): Promise => { + return daemon.request("file.search", { + limit: 50, + query, + task_id: task.id, + }) as Promise; + }, + [task.id], + ); + const openSymbol = useCallback((path: string) => openFileTab(path), [openFileTab]); const openDiffFile = useCallback( (path: string, hunks: EditHunk[] = []) => { setSelectedDiffFile(path); @@ -436,6 +447,8 @@ export default function TaskDetail({ task, snapshot, onOpenTask, onOpenPush }: P task_id: task.id, }) } + onGotoDefinition={searchSymbol} + onOpenSymbol={openSymbol} /> )} {activeSurface === "diff" && ( diff --git a/desktop/src/views/task-detail/FilesSurface.tsx b/desktop/src/views/task-detail/FilesSurface.tsx index 1452173..ebd34fd 100644 --- a/desktop/src/views/task-detail/FilesSurface.tsx +++ b/desktop/src/views/task-detail/FilesSurface.tsx @@ -3,7 +3,7 @@ import { lazy, Suspense } from "react"; import { cn } from "@/lib/utils"; -import type { FileDoc, ProjectFile } from "../../protocol"; +import type { FileDoc, ProjectFile, SymbolMatch } from "../../protocol"; import { ProjectFilesPanel } from "./ProjectFilesPanel"; const CodeEditor = lazy(async () => ({ @@ -41,6 +41,8 @@ export function FilesSurface({ rootPath, onRefresh, taskId, + onGotoDefinition, + onOpenSymbol, }: { projectFiles: ProjectFile[]; fileListError: string | null; @@ -55,6 +57,8 @@ export function FilesSurface({ rootPath?: string; onRefresh: () => void; taskId: string; + onGotoDefinition?: (query: string) => Promise; + onOpenSymbol?: (path: string, line: number, column: number) => void; }) { return (
@@ -115,6 +119,8 @@ export function FilesSurface({ doc={fileDoc} editable={editable} onSave={onSave} + onGotoDefinition={onGotoDefinition} + onOpenSymbol={onOpenSymbol} /> ) : ( From 70b81cc595fc3bd610c530fe96a1c671afcfe5fa Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Thu, 13 Aug 2026 00:11:45 +0200 Subject: [PATCH 4/8] feat(editor): integrate language server proxy --- .changeset/tall-pillows-search.md | 2 +- crates/warpforge-protocol/src/lib.rs | 42 +++ desktop/bun.lock | 5 + desktop/package.json | 1 + desktop/src/components/CodeEditor.tsx | 100 ++++++- desktop/src/components/QuickOpen.tsx | 21 +- desktop/src/components/TaskDetailActions.tsx | 18 ++ desktop/src/lib/codemirrorLanguages.ts | 37 +++ desktop/src/lib/lspClients.ts | 115 ++++++++ desktop/src/protocol.ts | 11 +- desktop/src/store/ui.ts | 5 + .../src/views/task-detail/FilesSurface.tsx | 3 + src/daemon/actor.rs | 57 ++++ src/daemon/lsp.rs | 276 ++++++++++++++++++ src/daemon/mod.rs | 1 + src/daemon/server.rs | 28 ++ src/daemon/wire.rs | 8 + 17 files changed, 710 insertions(+), 20 deletions(-) create mode 100644 desktop/src/lib/lspClients.ts create mode 100644 src/daemon/lsp.rs diff --git a/.changeset/tall-pillows-search.md b/.changeset/tall-pillows-search.md index 5379da2..1ae4236 100644 --- a/.changeset/tall-pillows-search.md +++ b/.changeset/tall-pillows-search.md @@ -2,4 +2,4 @@ "warpforge-desktop": patch --- -Add double-Shift quick-open file palette, go-to-definition (Cmd/Ctrl-click or CmdB), and a daemon `file.search` RPC backing symbol lookup. \ No newline at end of file +Add double-Shift quick-open file palette, go-to-definition (Cmd/Ctrl-click or CmdB), and a daemon `file.search` RPC backing symbol lookup. Add optional CodeMirror LSP integration through daemon-managed language-server processes, including TypeScript 7's native `tsc --lsp --stdio`. diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs index 5747814..68dbd57 100644 --- a/crates/warpforge-protocol/src/lib.rs +++ b/crates/warpforge-protocol/src/lib.rs @@ -614,6 +614,34 @@ pub enum Method { /// `{ ok, path }`. #[serde(rename = "bootstrap.writeConfig")] BootstrapWriteConfig { project: String, yaml: String }, + + // ── LSP ── + /// Ensure a language server is running for a task's workspace + language. + /// Reuses an existing server for the same (workspace, language). Returns + /// [`LspStartResult`]; `available: false` when no server binary is on PATH. + #[serde(rename = "lsp.start")] + LspStart { task_id: String, language: String }, + /// Forward an opaque LSP JSON-RPC message to a running server's stdin. + #[serde(rename = "lsp.send")] + LspSend { + server_id: String, + payload: serde_json::Value, + }, + /// Release one reference to a server; the process is killed once the last + /// editor using it closes. + #[serde(rename = "lsp.stop")] + LspStop { server_id: String }, +} + +/// Reply to [`Method::LspStart`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LspStartResult { + pub server_id: String, + pub available: bool, + /// Absolute workspace root the server was rooted at. Clients build + /// `file://` document URIs from it. Empty when unavailable. + pub root_path: String, } /// Answers collected by the desktop bootstrap wizard. Mirrors the daemon's @@ -785,6 +813,20 @@ pub enum Event { /// All nodes in the orchestration are done. #[serde(rename = "orchestration.allComplete")] OrchestrationAllComplete { graph_id: String, project: String }, + + // ── LSP ── + /// An opaque LSP JSON-RPC message from a server's stdout. + #[serde(rename = "lsp.message")] + LspMessage { + server_id: String, + payload: serde_json::Value, + }, + /// A language server exited (crashed or was stopped). + #[serde(rename = "lsp.exit")] + LspExit { + server_id: String, + code: Option, + }, } // ─── State DTOs ────────────────────────────────────────────────────────────── diff --git a/desktop/bun.lock b/desktop/bun.lock index 79c026d..482dd40 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -15,6 +15,7 @@ "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/lint": "^6.9.7", + "@codemirror/lsp-client": "^6.2.5", "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.43.4", @@ -153,6 +154,8 @@ "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], + "@codemirror/lsp-client": ["@codemirror/lsp-client@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/language": "^6.11.0", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.37.0", "@lezer/highlight": "^1.2.1", "marked": "^15.0.12", "vscode-languageserver-protocol": "^3.17.5" } }, "sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ=="], + "@codemirror/merge": ["@codemirror/merge@6.12.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/highlight": "^1.0.0", "style-mod": "^4.1.0" } }, "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w=="], "@codemirror/search": ["@codemirror/search@6.7.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="], @@ -1119,6 +1122,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], diff --git a/desktop/package.json b/desktop/package.json index e93b662..7c9983b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -25,6 +25,7 @@ "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/lint": "^6.9.7", + "@codemirror/lsp-client": "^6.2.5", "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.43.4", diff --git a/desktop/src/components/CodeEditor.tsx b/desktop/src/components/CodeEditor.tsx index 05cecac..c8b8633 100644 --- a/desktop/src/components/CodeEditor.tsx +++ b/desktop/src/components/CodeEditor.tsx @@ -1,15 +1,21 @@ import { lintGutter } from "@codemirror/lint"; -import { EditorState } from "@codemirror/state"; +import { Compartment, EditorState } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import { basicSetup } from "codemirror"; import { Check, Code, Eye, Save } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { codemirrorLanguageForPath } from "@/lib/codemirrorLanguages"; +import { + codemirrorLanguageForPath, + lspLanguageForPath, +} from "@/lib/codemirrorLanguages"; import { cmChromeForMode } from "@/lib/codemirrorTheme"; +import { acquireLspClient, releaseLspClient } from "@/lib/lspClients"; import { cn } from "@/lib/utils"; import { useThemeMode } from "@/hooks/useTheme"; +import { useUi } from "../store/ui"; + import type { FileDoc, SymbolMatch } from "../protocol"; import { Markdown } from "./Markdown"; @@ -37,12 +43,14 @@ function getMimeType(path: string): string { export function CodeEditor({ doc, editable, + taskId, onSave, onGotoDefinition, onOpenSymbol, }: { doc: FileDoc; editable: boolean; + taskId: string; onSave: (content: string) => void; /** Resolve a symbol under the cursor to project lines (go-to-definition). * When provided, ⌘/Ctrl-click and ⌘B run it. */ @@ -52,11 +60,17 @@ export function CodeEditor({ }) { const host = useRef(null); const viewRef = useRef(null); + const lspCompartment = useRef(new Compartment()); + const saveTimer = useRef | null>(null); + const lastSaved = useRef(null); const onSaveRef = useRef(onSave); const onGotoRef = useRef(onGotoDefinition); const onOpenSymbolRef = useRef(onOpenSymbol); + const lspEnabled = useUi((s) => s.lspEnabled); const [status, setStatus] = useState("clean"); const [preview, setPreview] = useState(false); + const [text, setText] = useState(doc.newText); + const [editorReady, setEditorReady] = useState(false); const [gotoResults, setGotoResults] = useState([]); const [gotoActive, setGotoActive] = useState(0); const markdown = isMarkdownPath(doc.path); @@ -64,7 +78,7 @@ export function CodeEditor({ const binaryImage = isBinaryImagePath(doc.path); const themeMode = useThemeMode(); const showPreview = (markdown || svgImage) && preview; - const previewText = viewRef.current?.state.doc.toString() ?? doc.newText; + const previewText = text; const isReadOnly = binaryImage || svgImage; useEffect(() => { @@ -80,7 +94,13 @@ export function CodeEditor({ if (!view) { return true; } + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } const current = view.state.doc.toString(); + lastSaved.current = current; + setText(current); onSaveRef.current(current); setStatus("saved"); return true; @@ -135,6 +155,10 @@ export function CodeEditor({ void codemirrorLanguageForPath(doc.path).then((language) => { if (disposed) return; + setStatus("clean"); + setText(doc.newText); + setPreview(false); + lastSaved.current = null; view = new EditorView({ parent, state: EditorState.create({ @@ -145,8 +169,9 @@ export function CodeEditor({ ...cmChromeForMode(themeMode), EditorView.lineWrapping, ...language, + lspCompartment.current.of([]), EditorState.readOnly.of(!editable || isReadOnly), -keymap.of([ + keymap.of([ { key: "Mod-s", run: flushSave }, ...(onGotoDefinition ? [{ key: "Mod-b", run: runGoto, preventDefault: true }] @@ -176,15 +201,31 @@ keymap.of([ return; } setStatus("unsaved"); + const next = u.state.doc.toString(); + setText(next); + if (saveTimer.current) { + clearTimeout(saveTimer.current); + } + saveTimer.current = setTimeout(() => { + lastSaved.current = next; + onSaveRef.current(next); + setStatus("saved"); + }, 600); }), ], }), }); viewRef.current = view; + setEditorReady(true); }); return () => { disposed = true; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + setEditorReady(false); view?.destroy(); if (viewRef.current === view) { viewRef.current = null; @@ -196,16 +237,59 @@ keymap.of([ useEffect(() => { const view = viewRef.current; if (!view) return; + if (doc.newText === lastSaved.current) { + return; + } if (status === "clean") { const current = view.state.doc.toString(); - if (current !== doc.newText) { - view.dispatch({ - changes: { from: 0, insert: doc.newText, to: current.length }, - }); + if (current === doc.newText) { + return; } + view.dispatch({ + changes: { from: 0, insert: doc.newText, to: current.length }, + }); + setText(doc.newText); + lastSaved.current = null; } }, [doc.newText, status]); + // Attach a language server to the editor when one is available for this file. + // Servers are shared per (workspace, language) and spawned lazily by the + // daemon; disabled files (diffs/history) and the LSP-off toggle skip this. + useEffect(() => { + const language = lspLanguageForPath(doc.path); + if (!editable || !editorReady || !lspEnabled || !language) { + return; + } + let cancelled = false; + let detach: (() => void) | null = null; + void acquireLspClient(taskId, language).then((acquired) => { + if (!acquired) { + return; + } + const view = viewRef.current; + if (cancelled || !view) { + releaseLspClient(acquired.key); + return; + } + const uri = `file://${acquired.rootPath}/${doc.path}`; + view.dispatch({ + effects: lspCompartment.current.reconfigure( + acquired.client.plugin(uri, language), + ), + }); + detach = () => { + viewRef.current?.dispatch({ effects: lspCompartment.current.reconfigure([]) }); + releaseLspClient(acquired.key); + }; + }); + return () => { + cancelled = true; + detach?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [doc.path, editable, editorReady, lspEnabled, taskId]); + return (
diff --git a/desktop/src/components/QuickOpen.tsx b/desktop/src/components/QuickOpen.tsx index c315411..04d4832 100644 --- a/desktop/src/components/QuickOpen.tsx +++ b/desktop/src/components/QuickOpen.tsx @@ -49,6 +49,15 @@ export function QuickOpen({ setActiveIndex(0); }, [query]); + useEffect(() => { + const el = listRef.current; + if (!el) return; + const active = el.querySelector("[data-active='true']"); + if (active && typeof active.scrollIntoView === "function") { + active.scrollIntoView({ block: "nearest" }); + } + }, [activeIndex]); + if (!open) return null; const choose = (path: string) => { @@ -80,15 +89,6 @@ export function QuickOpen({ } }; - useEffect(() => { - const el = listRef.current; - if (!el) return; - const active = el.querySelector("[data-active='true']"); - if (active && typeof active.scrollIntoView === "function") { - active.scrollIntoView({ block: "nearest" }); - } - }, [activeIndex]); - return (
@@ -96,6 +96,7 @@ export function QuickOpen({ setQuery(event.target.value)} onKeyDown={onKeyDown} @@ -151,4 +152,4 @@ export function QuickOpen({
); -} \ No newline at end of file +} diff --git a/desktop/src/components/TaskDetailActions.tsx b/desktop/src/components/TaskDetailActions.tsx index 9a198c2..3372aaa 100644 --- a/desktop/src/components/TaskDetailActions.tsx +++ b/desktop/src/components/TaskDetailActions.tsx @@ -5,6 +5,7 @@ import { ListTodo, MessageSquare, SquareTerminal, + Zap, } from "lucide-react"; import { memo } from "react"; @@ -23,6 +24,8 @@ export const TaskDetailActions = memo(function TaskDetailActions({ task }: { tas const setShowDiff = useUi((state) => state.setShowDiff); const setRightPanel = useUi((state) => state.setRightPanel); const setRuntimeOpen = useUi((state) => state.setRuntimeOpen); + const lspEnabled = useUi((state) => state.lspEnabled); + const toggleLsp = useUi((state) => state.toggleLsp); const togglePanel = (panel: "files" | "changes" | "subtasks") => { setShowDiff(true); @@ -31,6 +34,21 @@ export const TaskDetailActions = memo(function TaskDetailActions({ task }: { tas return (
+ +
{ const filename = path.split(/[\\/]/).pop()?.toLowerCase() ?? ""; const ext = filename.split(".").pop(); diff --git a/desktop/src/lib/lspClients.ts b/desktop/src/lib/lspClients.ts new file mode 100644 index 0000000..2c0039d --- /dev/null +++ b/desktop/src/lib/lspClients.ts @@ -0,0 +1,115 @@ +/** + * Shared LSP client registry. One `LSPClient` per (task workspace, language), + * reference-counted across every editor that opens a file of that language. + * The client talks to the daemon over a `Transport` that tunnels JSON-RPC as + * `lsp.send` requests and `lsp.message` events — the daemon owns the actual + * language-server process (spawned lazily, killed once the last editor closes). + */ +import { + languageServerExtensions, + LSPClient, + type Transport, +} from "@codemirror/lsp-client"; + +import { daemon } from "../daemon"; +import type { LspStartResult } from "../protocol"; + +type Resolved = { + client: LSPClient; + rootPath: string; + dispose: () => void; +}; + +type Entry = { + refs: number; + ready: Promise; +}; + +const entries = new Map(); + +async function startClient(taskId: string, language: string): Promise { + const res = (await daemon + .request("lsp.start", { language, task_id: taskId }) + .catch(() => null)) as LspStartResult | null; + if (!res?.available || !res.serverId) { + return null; + } + const serverId = res.serverId; + + const handlers = new Set<(value: string) => void>(); + const unsubscribe = daemon.subscribeEvents((ev) => { + if (ev.event === "lsp.message" && ev.data.server_id === serverId) { + const text = JSON.stringify(ev.data.payload); + handlers.forEach((handler) => handler(text)); + } + }); + + const transport: Transport = { + send(message) { + void daemon.request("lsp.send", { payload: JSON.parse(message), server_id: serverId }); + }, + subscribe(handler) { + handlers.add(handler); + }, + unsubscribe(handler) { + handlers.delete(handler); + }, + }; + + const client = new LSPClient({ + extensions: languageServerExtensions(), + rootUri: `file://${res.rootPath}`, + // rust-analyzer may need more than the package default of three seconds + // while it is warming a workspace for the first time. + timeout: 15_000, + }); + client.connect(transport); + + const dispose = () => { + unsubscribe(); + try { + client.disconnect(); + } catch { + // client may already be torn down + } + void daemon.request("lsp.stop", { server_id: serverId }).catch(() => {}); + }; + + return { client, dispose, rootPath: res.rootPath }; +} + +/** Acquire a shared client; returns null when no server is available. */ +export async function acquireLspClient( + taskId: string, + language: string, +): Promise<{ key: string; client: LSPClient; rootPath: string } | null> { + const key = `${taskId}:${language}`; + let entry = entries.get(key); + if (!entry) { + entry = { ready: startClient(taskId, language), refs: 0 }; + entries.set(key, entry); + } + entry.refs += 1; + const resolved = await entry.ready; + if (!resolved) { + entry.refs -= 1; + if (entry.refs <= 0) { + entries.delete(key); + } + return null; + } + return { client: resolved.client, key, rootPath: resolved.rootPath }; +} + +/** Release a reference; the process is killed once the last editor closes. */ +export function releaseLspClient(key: string): void { + const entry = entries.get(key); + if (!entry) { + return; + } + entry.refs -= 1; + if (entry.refs <= 0) { + entries.delete(key); + void entry.ready.then((resolved) => resolved?.dispose()); + } +} diff --git a/desktop/src/protocol.ts b/desktop/src/protocol.ts index df7dd92..264b57e 100644 --- a/desktop/src/protocol.ts +++ b/desktop/src/protocol.ts @@ -100,7 +100,16 @@ export type DaemonEvent = | { event: "orchestration.allComplete"; data: { graph_id: string; project: string }; - }; + } + // ── LSP ── + | { event: "lsp.message"; data: { server_id: string; payload: unknown } } + | { event: "lsp.exit"; data: { server_id: string; code: number | null } }; + +export interface LspStartResult { + serverId: string; + available: boolean; + rootPath: string; +} export function isEvent(msg: ServerMessage): msg is DaemonEvent { return "event" in msg; diff --git a/desktop/src/store/ui.ts b/desktop/src/store/ui.ts index 7bcf884..c73dc64 100644 --- a/desktop/src/store/ui.ts +++ b/desktop/src/store/ui.ts @@ -104,6 +104,8 @@ interface UiState extends SettingsState { sidebarWidth: number; /** Sidebar shrunk to its icon rail. */ sidebarCollapsed: boolean; + // Editor: language-server (LSP) features — persisted, user-toggled. + lspEnabled: boolean; setView: (v: View) => void; openTask: (id: string | null) => void; @@ -128,6 +130,7 @@ interface UiState extends SettingsState { setPinnedLayout: (id: string, layout: PinnedTileLayout) => void; setSidebarWidth: (w: number) => void; toggleSidebarCollapsed: () => void; + toggleLsp: () => void; } function clampFontSize(v: number): number { @@ -166,6 +169,7 @@ export const useUi = create()( autoNameTasks: true, newTaskWorktree: false, theoMod: false, + lspEnabled: true, setView: (view) => set({ openTaskId: null, openTaskNav: null, view }), openProject: (selectedProjectId) => @@ -262,6 +266,7 @@ export const useUi = create()( setAutoNameTasks: (autoNameTasks) => set({ autoNameTasks }), setNewTaskWorktree: (newTaskWorktree) => set({ newTaskWorktree }), setTheoMod: (theoMod) => set({ theoMod }), + toggleLsp: () => set((s) => ({ lspEnabled: !s.lspEnabled })), }), { name: "wf-ui", diff --git a/desktop/src/views/task-detail/FilesSurface.tsx b/desktop/src/views/task-detail/FilesSurface.tsx index ebd34fd..b5d7033 100644 --- a/desktop/src/views/task-detail/FilesSurface.tsx +++ b/desktop/src/views/task-detail/FilesSurface.tsx @@ -37,6 +37,7 @@ export function FilesSurface({ onCloseTab, fileDoc, editable, + taskId, onSave, rootPath, onRefresh, @@ -53,6 +54,7 @@ export function FilesSurface({ onCloseTab: (path: string) => void; fileDoc: FileDoc | null; editable: boolean; + taskId: string; onSave: (content: string) => void; rootPath?: string; onRefresh: () => void; @@ -118,6 +120,7 @@ export function FilesSurface({ key={`${fileDoc.path}:${editable}`} doc={fileDoc} editable={editable} + taskId={taskId} onSave={onSave} onGotoDefinition={onGotoDefinition} onOpenSymbol={onOpenSymbol} diff --git a/src/daemon/actor.rs b/src/daemon/actor.rs index 673289b..4991ac7 100644 --- a/src/daemon/actor.rs +++ b/src/daemon/actor.rs @@ -954,6 +954,21 @@ pub enum Command { stop_resources: bool, reply: oneshot::Sender>, }, + /// Ensure a language server is running for a task's workspace + language. + LspStart { + task_id: String, + language: String, + reply: oneshot::Sender, + }, + /// Forward an opaque LSP message to a running server's stdin. + LspSend { + server_id: String, + payload: serde_json::Value, + }, + /// Release one editor's reference to a language server. + LspStop { + server_id: String, + }, Shutdown { reply: oneshot::Sender<()>, }, @@ -1043,6 +1058,16 @@ pub enum Event { /// Orchestration pipeline event (plan created, node dispatched, etc.) #[allow(clippy::enum_variant_names)] OrchestrationEvent(crate::orchestration::OrchEvent), + /// An opaque LSP message from a language server's stdout. + LspMessage { + server_id: String, + payload: serde_json::Value, + }, + /// A language server exited. + LspExit { + server_id: String, + code: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1837,6 +1862,8 @@ pub struct Daemon { agents: AgentManager, services: ServiceManager, portforwards: PortForwardManager, + /// Language-server proxy: spawns and tunnels LSP servers per workspace. + lsp: super::lsp::LspManager, event_tx: broadcast::Sender, acp_tx: mpsc::UnboundedSender<(String, AcpUpdate)>, /// Sender back to this actor's command channel — used so background tasks @@ -1966,6 +1993,7 @@ impl Daemon { agents: AgentManager::new(agent_tx), services: ServiceManager::new(service_tx), portforwards: PortForwardManager::new(pf_tx), + lsp: super::lsp::LspManager::new(event_tx.clone()), event_tx: event_tx.clone(), acp_tx, cmd_tx: cmd_tx.clone(), @@ -2509,6 +2537,35 @@ impl Daemon { let result = self.remove_project(&name, stop_resources).await; let _ = reply.send(result); } + Command::LspStart { + task_id, + language, + reply, + } => { + let root = self.tasks.get(&task_id).and_then(|task| { + task.worktree + .clone() + .or_else(|| self.project_path(&task.project)) + }); + let result = match root { + Some(root) => { + let (server_id, available) = self.lsp.start(root.clone(), language); + wire::LspStartResult { + server_id, + available, + root_path: if available { root } else { String::new() }, + } + } + None => wire::LspStartResult { + server_id: String::new(), + available: false, + root_path: String::new(), + }, + }; + let _ = reply.send(result); + } + Command::LspSend { server_id, payload } => self.lsp.send(&server_id, payload), + Command::LspStop { server_id } => self.lsp.stop(&server_id), Command::Shutdown { .. } => unreachable!( "Shutdown commands are intercepted by the actor loop before handle_command" ), diff --git a/src/daemon/lsp.rs b/src/daemon/lsp.rs new file mode 100644 index 0000000..602d65f --- /dev/null +++ b/src/daemon/lsp.rs @@ -0,0 +1,276 @@ +//! Language-server proxy. The daemon spawns real language servers (rust-analyzer, +//! typescript-language-server, …) and tunnels their stdio to editor clients over +//! the WebSocket. Payloads stay opaque JSON — the daemon frames LSP messages +//! (`Content-Length` headers) but never interprets their semantics. +//! +//! Servers are lazy and shared: one process per (workspace root, language), +//! reference-counted across open editors and killed once the last editor closes +//! (`kill_on_drop`). A missing server binary is not an error — `start` reports +//! `available: false` and the editor falls back to syntax-only mode. + +use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; + +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{ChildStdin, ChildStdout, Command}; +use tokio::sync::{broadcast, mpsc}; +use uuid::Uuid; + +use super::actor::Event; + +/// Map an editor language id to its language-server command. Returns `None` when +/// warpforge does not know a server for that language (e.g. markdown). +fn server_command(language: &str, root: &str) -> Option<(String, Vec)> { + match language { + "rust" => Some(command("rust-analyzer", &[])), + "typescript" | "javascript" => Some(typescript_server_command(root)), + "go" => Some(command("gopls", &[])), + "python" => Some(command("pyright-langserver", &["--stdio"])), + "json" => Some(command("vscode-json-language-server", &["--stdio"])), + "css" => Some(command("vscode-css-language-server", &["--stdio"])), + "html" => Some(command("vscode-html-language-server", &["--stdio"])), + "yaml" => Some(command("yaml-language-server", &["--stdio"])), + _ => None, + } +} + +fn command(bin: &str, args: &[&str]) -> (String, Vec) { + ( + bin.to_string(), + args.iter().map(|arg| (*arg).to_string()).collect(), + ) +} + +/// TypeScript 7 is the native Go implementation and exposes LSP directly. +/// Older TypeScript releases expose the legacy tsserver protocol and need the +/// `typescript-language-server` adapter. Prefer the project's local TypeScript +/// binary so a TS7 workspace is not accidentally paired with unrelated global +/// TypeScript. +fn typescript_server_command(root: &str) -> (String, Vec) { + let local_tsc = Path::new(root).join("node_modules/.bin/tsc"); + if let Some(version) = typescript_version(&local_tsc) { + if version >= 7 { + return command(&local_tsc.to_string_lossy(), &["--lsp", "--stdio"]); + } + return local_typescript_adapter(root); + } + + if let Some(command) = native_typescript_command(Path::new("tsc")) { + return command; + } + + local_typescript_adapter(root) +} + +fn local_typescript_adapter(root: &str) -> (String, Vec) { + let local_adapter = Path::new(root).join("node_modules/.bin/typescript-language-server"); + if local_adapter.exists() { + return command(&local_adapter.to_string_lossy(), &["--stdio"]); + } + command("typescript-language-server", &["--stdio"]) +} + +fn native_typescript_command(bin: &Path) -> Option<(String, Vec)> { + typescript_version(bin) + .filter(|major| *major >= 7) + .map(|_| command(&bin.to_string_lossy(), &["--lsp", "--stdio"])) +} + +fn typescript_version(bin: &Path) -> Option { + std::process::Command::new(bin) + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|version| typescript_major(&version)) +} + +fn typescript_major(version: &str) -> Option { + version + .split_whitespace() + .find_map(|part| part.split('.').next()?.parse().ok()) +} + +struct ServerHandle { + stdin_tx: mpsc::UnboundedSender>, + key: (String, String), + refs: usize, + /// Kept alive to hold the process; dropping it kills the server. + _child: tokio::process::Child, +} + +pub struct LspManager { + event_tx: broadcast::Sender, + servers: HashMap, + by_key: HashMap<(String, String), String>, +} + +impl LspManager { + pub fn new(event_tx: broadcast::Sender) -> Self { + Self { + event_tx, + servers: HashMap::new(), + by_key: HashMap::new(), + } + } + + /// Ensure a server for `(root, language)` is running. Returns its id and + /// whether a server binary was available. Reuses an existing process. + pub fn start(&mut self, root: String, language: String) -> (String, bool) { + let key = (root.clone(), language.clone()); + if let Some(id) = self.by_key.get(&key).cloned() { + let alive = self + .servers + .get_mut(&id) + .is_some_and(|handle| matches!(handle._child.try_wait(), Ok(None))); + if alive { + if let Some(handle) = self.servers.get_mut(&id) { + handle.refs += 1; + return (id, true); + } + } else { + self.servers.remove(&id); + self.by_key.remove(&key); + } + } + + let Some((bin, args)) = server_command(&language, &root) else { + return (String::new(), false); + }; + + let mut child = match Command::new(&bin) + .args(&args) + .current_dir(&root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + { + Ok(child) => child, + Err(_) => return (String::new(), false), // binary not installed + }; + + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + let server_id = Uuid::new_v4().to_string(); + + let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::>(); + tokio::spawn(write_loop(stdin, stdin_rx)); + + let event_tx = self.event_tx.clone(); + let id = server_id.clone(); + tokio::spawn(async move { + let _ = read_loop(stdout, &id, &event_tx).await; + let _ = event_tx.send(Event::LspExit { + server_id: id, + code: None, + }); + }); + + self.servers.insert( + server_id.clone(), + ServerHandle { + stdin_tx, + key: key.clone(), + refs: 1, + _child: child, + }, + ); + self.by_key.insert(key, server_id.clone()); + (server_id, true) + } + + /// Forward an opaque LSP message to a server's stdin. + pub fn send(&self, server_id: &str, payload: serde_json::Value) { + if let Some(handle) = self.servers.get(server_id) { + if let Ok(bytes) = serde_json::to_vec(&payload) { + let _ = handle.stdin_tx.send(bytes); + } + } + } + + /// Release one editor's reference; kill the process when none remain. + pub fn stop(&mut self, server_id: &str) { + let drop_key = match self.servers.get_mut(server_id) { + Some(handle) => { + handle.refs = handle.refs.saturating_sub(1); + if handle.refs == 0 { + Some(handle.key.clone()) + } else { + None + } + } + None => None, + }; + if let Some(key) = drop_key { + self.servers.remove(server_id); // drop → kill_on_drop + self.by_key.remove(&key); + } + } +} + +/// Write LSP `Content-Length`-framed messages to a server's stdin. +async fn write_loop(mut stdin: ChildStdin, mut rx: mpsc::UnboundedReceiver>) { + while let Some(bytes) = rx.recv().await { + let header = format!("Content-Length: {}\r\n\r\n", bytes.len()); + if stdin.write_all(header.as_bytes()).await.is_err() + || stdin.write_all(&bytes).await.is_err() + || stdin.flush().await.is_err() + { + break; + } + } +} + +/// Parse LSP framing from a server's stdout and emit each message as an event. +async fn read_loop( + stdout: ChildStdout, + server_id: &str, + event_tx: &broadcast::Sender, +) -> std::io::Result<()> { + let mut reader = BufReader::new(stdout); + loop { + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).await? == 0 { + return Ok(()); // EOF + } + let trimmed = line.trim_end(); + if trimmed.is_empty() { + break; // end of headers + } + if let Some(value) = trimmed.strip_prefix("Content-Length:") { + content_length = value.trim().parse().unwrap_or(0); + } + } + if content_length == 0 { + continue; + } + let mut buf = vec![0u8; content_length]; + reader.read_exact(&mut buf).await?; + if let Ok(payload) = serde_json::from_slice::(&buf) { + let _ = event_tx.send(Event::LspMessage { + server_id: server_id.to_string(), + payload, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::typescript_major; + + #[test] + fn parses_typescript_major_versions() { + assert_eq!(typescript_major("Version 7.0.2\n"), Some(7)); + assert_eq!(typescript_major("Version 5.9.3"), Some(5)); + assert_eq!(typescript_major("unexpected"), None); + } +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 3dbf9d1..06eaa6e 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -16,6 +16,7 @@ pub mod agent_probe; pub mod agents; pub mod claude_auth; pub mod diff; +pub mod lsp; pub mod prompt; pub mod server; pub mod sessions; diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 86cedec..328b0ee 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -383,6 +383,31 @@ async fn dispatch( handle.send(Command::StopRuntime).await; Ok(json!(null)) } + LspStart { task_id, language } => { + let (tx, rx) = oneshot::channel(); + handle + .send(Command::LspStart { + task_id, + language, + reply: tx, + }) + .await; + match rx.await { + Ok(result) => Ok(json!(result)), + Err(_) => Err(wire::RpcError { + code: wire::ErrorCode::Internal, + message: "daemon dropped the lsp.start reply".into(), + }), + } + } + LspSend { server_id, payload } => { + handle.send(Command::LspSend { server_id, payload }).await; + Ok(json!(null)) + } + LspStop { server_id } => { + handle.send(Command::LspStop { server_id }).await; + Ok(json!(null)) + } ServiceLogs { project, service, @@ -1353,6 +1378,9 @@ fn method_is_mutation(method: &wire::Method) -> bool { | WorkflowList { .. } | BootstrapFinalize { .. } | BootstrapReadConfig { .. } + | LspStart { .. } + | LspSend { .. } + | LspStop { .. } ) } diff --git a/src/daemon/wire.rs b/src/daemon/wire.rs index d01e448..6277df6 100644 --- a/src/daemon/wire.rs +++ b/src/daemon/wire.rs @@ -229,6 +229,14 @@ pub fn to_wire(ev: &Event) -> Option { Event::ProjectConfigChanged(state) => { Some(wire::Event::ProjectConfigChanged(state.clone())) } + Event::LspMessage { server_id, payload } => Some(wire::Event::LspMessage { + server_id: server_id.clone(), + payload: payload.clone(), + }), + Event::LspExit { server_id, code } => Some(wire::Event::LspExit { + server_id: server_id.clone(), + code: *code, + }), // Internal-only: the wire conveys terminals via screen/exited events. Event::AgentSpawned { .. } | Event::AgentStatus { .. } => None, // Orchestration events forwarded from the orchestrator actor. From ef23baeeac38d733e6ebb0542e5af44f6faf98ac Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Thu, 13 Aug 2026 17:07:31 +0200 Subject: [PATCH 5/8] fix(editor): polish LSP editor UX --- .changeset/quiet-editors-glow.md | 5 + desktop/src/components/CodeEditor.tsx | 169 +++++---- desktop/src/components/MergeDiff.tsx | 12 +- desktop/src/globals.css | 334 ++++++++++++++++++ desktop/src/lib/codemirrorLanguages.ts | 20 ++ desktop/src/lib/lspClients.ts | 96 ++++- desktop/src/views/TaskDetail.tsx | 16 +- .../src/views/task-detail/FilesSurface.tsx | 6 + 8 files changed, 587 insertions(+), 71 deletions(-) create mode 100644 .changeset/quiet-editors-glow.md diff --git a/.changeset/quiet-editors-glow.md b/.changeset/quiet-editors-glow.md new file mode 100644 index 0000000..17428f8 --- /dev/null +++ b/.changeset/quiet-editors-glow.md @@ -0,0 +1,5 @@ +--- +"warpforge-desktop": patch +--- + +Keep editor changes manual until explicit save, style CodeMirror diagnostics and LSP tooltips for the dark UI, and parse TSX/JSX documents with their React language ids. diff --git a/desktop/src/components/CodeEditor.tsx b/desktop/src/components/CodeEditor.tsx index c8b8633..c696a8c 100644 --- a/desktop/src/components/CodeEditor.tsx +++ b/desktop/src/components/CodeEditor.tsx @@ -1,22 +1,23 @@ import { lintGutter } from "@codemirror/lint"; +import { jumpToDefinition } from "@codemirror/lsp-client"; import { Compartment, EditorState } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import { basicSetup } from "codemirror"; import { Check, Code, Eye, Save } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useThemeMode } from "@/hooks/useTheme"; import { codemirrorLanguageForPath, + lspDocumentLanguageForPath, lspLanguageForPath, } from "@/lib/codemirrorLanguages"; import { cmChromeForMode } from "@/lib/codemirrorTheme"; import { acquireLspClient, releaseLspClient } from "@/lib/lspClients"; import { cn } from "@/lib/utils"; -import { useThemeMode } from "@/hooks/useTheme"; - -import { useUi } from "../store/ui"; import type { FileDoc, SymbolMatch } from "../protocol"; +import { useUi } from "../store/ui"; import { Markdown } from "./Markdown"; type SaveStatus = "clean" | "unsaved" | "saved"; @@ -47,6 +48,8 @@ export function CodeEditor({ onSave, onGotoDefinition, onOpenSymbol, + gotoLocation, + onGotoLocationHandled, }: { doc: FileDoc; editable: boolean; @@ -57,11 +60,14 @@ export function CodeEditor({ onGotoDefinition?: (query: string) => Promise; /** Open a found symbol's file at its line/column. */ onOpenSymbol?: (path: string, line: number, column: number) => void; + /** Move the editor to a pending 1-based source location after it loads. */ + gotoLocation?: { line: number; column: number }; + onGotoLocationHandled?: () => void; }) { const host = useRef(null); const viewRef = useRef(null); const lspCompartment = useRef(new Compartment()); - const saveTimer = useRef | null>(null); + const gotoLocationKey = useRef(null); const lastSaved = useRef(null); const onSaveRef = useRef(onSave); const onGotoRef = useRef(onGotoDefinition); @@ -73,6 +79,8 @@ export function CodeEditor({ const [editorReady, setEditorReady] = useState(false); const [gotoResults, setGotoResults] = useState([]); const [gotoActive, setGotoActive] = useState(0); + const [gotoPending, setGotoPending] = useState(false); + const [gotoQuery, setGotoQuery] = useState(""); const markdown = isMarkdownPath(doc.path); const svgImage = isSvgPath(doc.path); const binaryImage = isBinaryImagePath(doc.path); @@ -94,10 +102,6 @@ export function CodeEditor({ if (!view) { return true; } - if (saveTimer.current) { - clearTimeout(saveTimer.current); - saveTimer.current = null; - } const current = view.state.doc.toString(); lastSaved.current = current; setText(current); @@ -109,11 +113,20 @@ export function CodeEditor({ const runGoto = useCallback((): boolean => { const view = viewRef.current; const save = onGotoRef.current; - if (!view || !save) { + if (!view) { + return false; + } + if (jumpToDefinition(view)) { + setGotoResults([]); + setGotoPending(false); + setGotoQuery(""); + return true; + } + if (!save) { return false; } const head = view.state.selection.main.head; - const word = view.state.wordAt(head); + const word = view.state.wordAt(head) ?? (head > 0 ? view.state.wordAt(head - 1) : null); if (!word) { return false; } @@ -122,13 +135,20 @@ export function CodeEditor({ return false; } setGotoResults([]); - void save(query).then((results) => { - if (!results.length) { - return; - } - setGotoResults(results.slice(0, 12)); - setGotoActive(0); - }); + setGotoQuery(query); + setGotoPending(true); + void save(query) + .then((results) => { + setGotoPending(false); + if (!results.length) { + return; + } + setGotoResults(results.slice(0, 12)); + setGotoActive(0); + }) + .catch(() => { + setGotoPending(false); + }); return true; }, []); @@ -140,6 +160,7 @@ export function CodeEditor({ } const open = onOpenSymbolRef.current; setGotoResults([]); + setGotoQuery(""); open?.(hit.path, hit.line, hit.column); }, [gotoResults], @@ -173,9 +194,7 @@ export function CodeEditor({ EditorState.readOnly.of(!editable || isReadOnly), keymap.of([ { key: "Mod-s", run: flushSave }, - ...(onGotoDefinition - ? [{ key: "Mod-b", run: runGoto, preventDefault: true }] - : []), + ...(onGotoDefinition ? [{ key: "Mod-b", run: runGoto, preventDefault: true }] : []), ]), ...(onGotoDefinition ? [ @@ -203,14 +222,6 @@ export function CodeEditor({ setStatus("unsaved"); const next = u.state.doc.toString(); setText(next); - if (saveTimer.current) { - clearTimeout(saveTimer.current); - } - saveTimer.current = setTimeout(() => { - lastSaved.current = next; - onSaveRef.current(next); - setStatus("saved"); - }, 600); }), ], }), @@ -221,10 +232,6 @@ export function CodeEditor({ return () => { disposed = true; - if (saveTimer.current) { - clearTimeout(saveTimer.current); - saveTimer.current = null; - } setEditorReady(false); view?.destroy(); if (viewRef.current === view) { @@ -258,7 +265,8 @@ export function CodeEditor({ // daemon; disabled files (diffs/history) and the LSP-off toggle skip this. useEffect(() => { const language = lspLanguageForPath(doc.path); - if (!editable || !editorReady || !lspEnabled || !language) { + const documentLanguage = lspDocumentLanguageForPath(doc.path); + if (!editable || !editorReady || !lspEnabled || !language || !documentLanguage) { return; } let cancelled = false; @@ -275,7 +283,7 @@ export function CodeEditor({ const uri = `file://${acquired.rootPath}/${doc.path}`; view.dispatch({ effects: lspCompartment.current.reconfigure( - acquired.client.plugin(uri, language), + acquired.client.plugin(uri, documentLanguage), ), }); detach = () => { @@ -290,6 +298,30 @@ export function CodeEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [doc.path, editable, editorReady, lspEnabled, taskId]); + useEffect(() => { + const view = viewRef.current; + if (!view || !editorReady || !gotoLocation) { + if (!gotoLocation) { + gotoLocationKey.current = null; + } + return; + } + const key = `${gotoLocation.line}:${gotoLocation.column}`; + if (gotoLocationKey.current === key) { + return; + } + const lineNumber = Math.min(Math.max(gotoLocation.line, 1), view.state.doc.lines); + const line = view.state.doc.line(lineNumber); + const column = Math.min(Math.max(gotoLocation.column - 1, 0), line.length); + view.dispatch({ + selection: { anchor: line.from + column }, + scrollIntoView: true, + userEvent: "select.goto", + }); + gotoLocationKey.current = key; + onGotoLocationHandled?.(); + }, [editorReady, gotoLocation, onGotoLocationHandled]); + return (
@@ -368,37 +400,46 @@ export function CodeEditor({ )} style={{ fontSize: "var(--app-mono-font-size)" }} /> - {gotoResults.length > 0 && !showPreview && ( -
-
- Go to definition -
-
- {gotoResults.map((hit, index) => ( - - ))} + {(gotoResults.length > 0 || gotoPending || (gotoQuery && !gotoPending)) && + !showPreview && ( +
+
Go to definition
+ {gotoPending ? ( +
+ Searching for {gotoQuery}… +
+ ) : gotoResults.length === 0 ? ( +
+ No definition found for {gotoQuery} +
+ ) : ( +
+ {gotoResults.map((hit, index) => ( + + ))} +
+ )}
-
- )} + )} {showPreview && (
{svgImage ? ( diff --git a/desktop/src/components/MergeDiff.tsx b/desktop/src/components/MergeDiff.tsx index 18f1205..8f899b2 100644 --- a/desktop/src/components/MergeDiff.tsx +++ b/desktop/src/components/MergeDiff.tsx @@ -17,9 +17,8 @@ type SaveStatus = "clean" | "unsaved" | "saved"; /** * Editable side-by-side review of one file: HEAD (left, read-only) vs the * working tree (right, editable) via CodeMirror's MergeView. Per-chunk revert - * arrows (↩) discard an agent change; edits to the right pane auto-save (debounced) - * back to the working tree. ⌘S saves now; "Discard edits" restores the file to - * how the agent left it. + * arrows (↩) discard an agent change; edits stay unsaved until ⌘S or the save + * action runs. "Discard edits" restores the file to how the agent left it. */ export function MergeDiff({ doc, @@ -76,7 +75,12 @@ export function MergeDiff({ void codemirrorLanguageForPath(doc.path).then((lang) => { if (disposed) return; - const common: Extension[] = [lineNumbers(), ...cmChromeForMode(themeMode), EditorView.lineWrapping, ...lang]; + const common: Extension[] = [ + lineNumbers(), + ...cmChromeForMode(themeMode), + EditorView.lineWrapping, + ...lang, + ]; view = new MergeView({ a: { doc: doc.oldText, diff --git a/desktop/src/globals.css b/desktop/src/globals.css index 0c31124..26bdabe 100644 --- a/desktop/src/globals.css +++ b/desktop/src/globals.css @@ -209,6 +209,340 @@ textarea { color: hsl(var(--foreground)); } +/* LSP hover, completion, and signature help use CodeMirror's raw tooltips. */ +.warpforge-code-editor .cm-tooltip { + z-index: 500; + box-sizing: border-box; + max-width: min(38rem, calc(100vw - 2rem)); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + box-shadow: 0 12px 32px hsl(0 0% 0% / 0.38); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: calc(var(--app-font-size) * 0.785); + line-height: 1.4; +} + +.warpforge-code-editor .cm-tooltip-arrow { + height: 7px; + width: 14px; +} + +.warpforge-code-editor .cm-tooltip-above .cm-tooltip-arrow { + bottom: -7px; +} + +.warpforge-code-editor .cm-tooltip-below .cm-tooltip-arrow { + top: -7px; +} + +.warpforge-code-editor .cm-tooltip-above .cm-tooltip-arrow::before, +.warpforge-code-editor .cm-tooltip-above .cm-tooltip-arrow::after, +.warpforge-code-editor .cm-tooltip-below .cm-tooltip-arrow::before, +.warpforge-code-editor .cm-tooltip-below .cm-tooltip-arrow::after { + border-left-width: 7px; + border-right-width: 7px; +} + +.warpforge-code-editor .cm-tooltip-above .cm-tooltip-arrow::before { + border-top-color: hsl(var(--border)); +} + +.warpforge-code-editor .cm-tooltip-above .cm-tooltip-arrow::after { + border-top-color: hsl(var(--popover)); +} + +.warpforge-code-editor .cm-tooltip-below .cm-tooltip-arrow::before { + border-bottom-color: hsl(var(--border)); +} + +.warpforge-code-editor .cm-tooltip-below .cm-tooltip-arrow::after { + border-bottom-color: hsl(var(--popover)); +} + +.warpforge-code-editor .cm-lsp-hover-tooltip, +.warpforge-code-editor .cm-lsp-signature-tooltip { + max-width: min(38rem, calc(100vw - 2rem)); + max-height: min(20rem, 45vh); + padding: 0.55rem 0.75rem; + overflow: auto; +} + +.warpforge-code-editor .cm-lsp-documentation p, +.warpforge-code-editor .cm-lsp-documentation pre, +.warpforge-code-editor .cm-lsp-documentation ul, +.warpforge-code-editor .cm-lsp-documentation ol { + margin: 0.3rem 0; +} + +.warpforge-code-editor .cm-lsp-documentation > :first-child { + margin-top: 0; +} + +.warpforge-code-editor .cm-lsp-documentation > :last-child { + margin-bottom: 0; +} + +.warpforge-code-editor .cm-lsp-documentation pre { + max-width: 100%; + padding: 0.45rem 0.55rem; + overflow-x: auto; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 0.125rem); + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.92em; +} + +.warpforge-code-editor .cm-lsp-documentation code { + padding: 0.05rem 0.2rem; + border-radius: 0.2rem; + background: hsl(var(--secondary)); + color: hsl(var(--primary)); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.92em; +} + +.warpforge-code-editor .cm-lsp-documentation pre code { + padding: 0; + background: transparent; + color: inherit; +} + +.warpforge-code-editor .cm-lsp-documentation a { + color: hsl(var(--primary)); + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.warpforge-code-editor .cm-lsp-documentation h1, +.warpforge-code-editor .cm-lsp-documentation h2, +.warpforge-code-editor .cm-lsp-documentation h3, +.warpforge-code-editor .cm-lsp-documentation h4 { + margin: 0.45rem 0 0.25rem; + color: hsl(var(--foreground)); + font-size: 1em; + font-weight: 600; +} + +.warpforge-code-editor .cm-lsp-signature-tooltip { + font-family: ui-sans-serif, system-ui, sans-serif; +} + +.warpforge-code-editor .cm-lsp-signature { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: pre-wrap; +} + +.warpforge-code-editor .cm-lsp-active-parameter { + color: hsl(var(--primary)); + font-weight: 700; +} + +.warpforge-code-editor .cm-lsp-signature-num { + color: hsl(var(--muted-foreground)); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.warpforge-code-editor .cm-tooltip.cm-tooltip-autocomplete { + min-width: 16rem; + max-width: min(44rem, calc(100vw - 2rem)); + overflow: hidden; +} + +.warpforge-code-editor .cm-tooltip-autocomplete > ul { + max-width: none; + max-height: min(18rem, 45vh); + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.warpforge-code-editor .cm-tooltip-autocomplete > ul > li, +.warpforge-code-editor .cm-tooltip-autocomplete > ul > completion-section { + padding: 0.2rem 0.45rem; + line-height: 1.35; +} + +.warpforge-code-editor .cm-tooltip-autocomplete > ul > li[aria-selected] { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +.warpforge-code-editor .cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail { + color: hsl(var(--muted-foreground)); +} + +.warpforge-code-editor .cm-completionMatchedText { + color: hsl(var(--primary)); + text-decoration-color: hsl(var(--primary)); +} + +.warpforge-code-editor .cm-completionDetail { + color: hsl(var(--muted-foreground)); +} + +.warpforge-code-editor .cm-completionIcon { + color: hsl(var(--info)); +} + +.warpforge-code-editor .cm-tooltip.cm-completionInfo { + max-width: min(30rem, calc(100vw - 2rem)); + padding: 0; + overflow: hidden auto; +} + +.warpforge-code-editor .cm-lsp-completion-documentation { + max-height: min(18rem, 40vh); + overflow: auto; +} + +/* Diagnostics — keep CodeMirror's markers readable inside the dark editor. */ +.warpforge-code-editor .cm-tooltip.cm-tooltip-lint { + max-width: min(36rem, calc(100vw - 2rem)); + padding: 0; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + box-shadow: 0 12px 32px hsl(0 0% 0% / 0.38); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: calc(var(--app-font-size) * 0.785); +} + +.warpforge-code-editor .cm-tooltip-lint { + min-width: 14rem; + padding: 0; + margin: 0; + list-style: none; +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnostic { + display: block; + padding: 0.45rem 0.65rem 0.45rem 0.75rem; + margin: 0; + border-left: 3px solid hsl(var(--muted-foreground)); + white-space: pre-wrap; + line-height: 1.35; +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnostic + .cm-diagnostic { + border-top: 1px solid hsl(var(--border)); +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnostic-error { + border-left-color: hsl(var(--destructive)); +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnostic-warning { + border-left-color: hsl(var(--warn)); +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnostic-info { + border-left-color: hsl(var(--info)); +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnostic-hint { + border-left-color: hsl(var(--muted-foreground)); +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnosticSource { + margin-top: 0.2rem; + color: hsl(var(--muted-foreground)); + font-size: 0.85em; +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnosticAction { + padding: 0.15rem 0.4rem; + margin: 0.25rem 0 0 0.5rem; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 0.125rem); + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + font: inherit; + cursor: pointer; +} + +.warpforge-code-editor .cm-tooltip-lint .cm-diagnosticAction:hover { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +.warpforge-code-editor .cm-lintRange { + padding-bottom: 0; + background-image: none; + text-decoration-line: underline; + text-decoration-style: wavy; + text-underline-offset: 0.15em; +} + +.warpforge-code-editor .cm-lintRange-error { + text-decoration-color: hsl(var(--destructive)); +} + +.warpforge-code-editor .cm-lintRange-warning { + text-decoration-color: hsl(var(--warn)); +} + +.warpforge-code-editor .cm-lintRange-info { + text-decoration-color: hsl(var(--info)); +} + +.warpforge-code-editor .cm-lintRange-hint { + text-decoration-color: hsl(var(--muted-foreground)); +} + +.warpforge-code-editor .cm-lintRange-active { + background-color: hsl(var(--accent) / 0.7); +} + +.warpforge-code-editor .cm-lint-marker { + width: 0.85rem; + height: 0.85rem; + margin: 0.1rem 0.075rem; + background-image: none; + opacity: 0.9; +} + +.warpforge-code-editor .cm-lint-marker-error { + content: ""; + border-radius: 999px; + background: hsl(var(--destructive)); +} + +.warpforge-code-editor .cm-lint-marker-warning { + content: ""; + background: hsl(var(--warn)); + clip-path: polygon(50% 4%, 96% 94%, 4% 94%); +} + +.warpforge-code-editor .cm-lint-marker-info { + content: ""; + border-radius: 0.15rem; + background: hsl(var(--info)); +} + +.warpforge-code-editor .cm-lint-marker-hint { + content: ""; + border-radius: 0.15rem; + background: hsl(var(--muted-foreground)); + transform: rotate(45deg) scale(0.7); +} + +.warpforge-code-editor .cm-panel.cm-panel-lint { + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); +} + +.warpforge-code-editor .cm-panel.cm-panel-lint ul [aria-selected] { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + /* Resize handles for Mission Control grid */ .react-resizable-handle { position: absolute; diff --git a/desktop/src/lib/codemirrorLanguages.ts b/desktop/src/lib/codemirrorLanguages.ts index c6e8873..c924215 100644 --- a/desktop/src/lib/codemirrorLanguages.ts +++ b/desktop/src/lib/codemirrorLanguages.ts @@ -37,6 +37,26 @@ export function lspLanguageForPath(path: string): string | null { } } +/** Exact document language id sent in `textDocument/didOpen`. React files need + * their React-specific ids so TypeScript parses JSX instead of plain TS/JS. */ +export function lspDocumentLanguageForPath(path: string): string | null { + const ext = path.split(/[\\/]/).pop()?.toLowerCase().split(".").pop(); + switch (ext) { + case "tsx": + return "typescriptreact"; + case "jsx": + return "javascriptreact"; + case "ts": + return "typescript"; + case "js": + case "mjs": + case "cjs": + return "javascript"; + default: + return lspLanguageForPath(path); + } +} + export async function codemirrorLanguageForPath(path: string): Promise { const filename = path.split(/[\\/]/).pop()?.toLowerCase() ?? ""; const ext = filename.split(".").pop(); diff --git a/desktop/src/lib/lspClients.ts b/desktop/src/lib/lspClients.ts index 2c0039d..8a67baa 100644 --- a/desktop/src/lib/lspClients.ts +++ b/desktop/src/lib/lspClients.ts @@ -5,11 +5,15 @@ * `lsp.send` requests and `lsp.message` events — the daemon owns the actual * language-server process (spawned lazily, killed once the last editor closes). */ +import { setDiagnostics } from "@codemirror/lint"; import { languageServerExtensions, LSPClient, + LSPPlugin, + type LSPClientExtension, type Transport, } from "@codemirror/lsp-client"; +import { ViewPlugin, type EditorView } from "@codemirror/view"; import { daemon } from "../daemon"; import type { LspStartResult } from "../protocol"; @@ -27,6 +31,96 @@ type Entry = { const entries = new Map(); +type PullDiagnostic = { + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + message: string; + severity?: number; +}; + +type PullDiagnosticReport = { + kind: "full" | "unchanged"; + items?: PullDiagnostic[]; +}; + +function pullDiagnostics(view: EditorView) { + const plugin = LSPPlugin.get(view); + if (!plugin) { + return; + } + plugin.client.sync(); + void plugin.client + .request<{ textDocument: { uri: string } }, PullDiagnosticReport>("textDocument/diagnostic", { + textDocument: { uri: plugin.uri }, + }) + .then((report) => { + const current = LSPPlugin.get(view); + if (!current || report.kind !== "full") { + return; + } + const diagnostics = (report.items ?? []).map((item) => ({ + from: current.unsyncedChanges.mapPos( + current.fromPosition(item.range.start, current.syncedDoc), + ), + to: current.unsyncedChanges.mapPos(current.fromPosition(item.range.end, current.syncedDoc)), + severity: + item.severity === 2 + ? ("warning" as const) + : item.severity === 3 + ? ("info" as const) + : item.severity === 4 + ? ("hint" as const) + : ("error" as const), + message: item.message, + })); + view.dispatch(setDiagnostics(view.state, diagnostics)); + }) + .catch(() => { + // The server may be warming up or shutting down; next edit retries. + }); +} + +const pullDiagnosticsExtension: LSPClientExtension = { + clientCapabilities: { + textDocument: { + diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false }, + }, + }, + editorExtension: ViewPlugin.fromClass( + class { + private timer: ReturnType | null = null; + + constructor(private readonly view: EditorView) { + this.schedule(0); + } + + update(update: { docChanged: boolean }) { + if (update.docChanged) { + this.schedule(500); + } + } + + destroy() { + if (this.timer) { + clearTimeout(this.timer); + } + } + + private schedule(delay: number) { + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = null; + pullDiagnostics(this.view); + }, delay); + } + }, + ), +}; + async function startClient(taskId: string, language: string): Promise { const res = (await daemon .request("lsp.start", { language, task_id: taskId }) @@ -57,7 +151,7 @@ async function startClient(taskId: string, language: string): Promise(null); const [openFileTabs, setOpenFileTabs] = useState([]); const [activeFilePath, setActiveFilePath] = useState(null); + const [gotoLocation, setGotoLocation] = useState<{ + path: string; + line: number; + column: number; + } | null>(null); const [selectedDiffFile, setSelectedDiffFile] = useState(null); const [commitExpanded, setCommitExpanded] = useState(false); const diffView = useUi((s) => s.diffView); @@ -165,11 +170,12 @@ export default function TaskDetail({ task, snapshot, onOpenTask, onOpenPush }: P } = useTaskQueries(task.id, activeFilePath, activeTabForQuery, task.updatedAt); const openFileTab = useCallback( - (path: string) => { + (path: string, location?: { line: number; column: number }) => { setOpenFileTabs((tabs) => (tabs.includes(path) ? tabs : [...tabs, path])); setActiveFilePath(path); setActiveSurface("files"); setShowDiff(true); + setGotoLocation(location ? { path, ...location } : null); }, [setActiveSurface, setShowDiff], ); @@ -183,7 +189,11 @@ export default function TaskDetail({ task, snapshot, onOpenTask, onOpenPush }: P }, [task.id], ); - const openSymbol = useCallback((path: string) => openFileTab(path), [openFileTab]); + const openSymbol = useCallback( + (path: string, line: number, column: number) => openFileTab(path, { line, column }), + [openFileTab], + ); + const clearGotoLocation = useCallback(() => setGotoLocation(null), []); const openDiffFile = useCallback( (path: string, hunks: EditHunk[] = []) => { setSelectedDiffFile(path); @@ -449,6 +459,8 @@ export default function TaskDetail({ task, snapshot, onOpenTask, onOpenPush }: P } onGotoDefinition={searchSymbol} onOpenSymbol={openSymbol} + gotoLocation={gotoLocation} + onGotoLocationHandled={clearGotoLocation} /> )} {activeSurface === "diff" && ( diff --git a/desktop/src/views/task-detail/FilesSurface.tsx b/desktop/src/views/task-detail/FilesSurface.tsx index b5d7033..bec48e3 100644 --- a/desktop/src/views/task-detail/FilesSurface.tsx +++ b/desktop/src/views/task-detail/FilesSurface.tsx @@ -44,6 +44,8 @@ export function FilesSurface({ taskId, onGotoDefinition, onOpenSymbol, + gotoLocation, + onGotoLocationHandled, }: { projectFiles: ProjectFile[]; fileListError: string | null; @@ -61,6 +63,8 @@ export function FilesSurface({ taskId: string; onGotoDefinition?: (query: string) => Promise; onOpenSymbol?: (path: string, line: number, column: number) => void; + gotoLocation?: { path: string; line: number; column: number } | null; + onGotoLocationHandled?: () => void; }) { return (
@@ -124,6 +128,8 @@ export function FilesSurface({ onSave={onSave} onGotoDefinition={onGotoDefinition} onOpenSymbol={onOpenSymbol} + gotoLocation={gotoLocation?.path === fileDoc.path ? gotoLocation : undefined} + onGotoLocationHandled={onGotoLocationHandled} /> ) : ( From 9dbe247fbcbe90742061c4d71cb0175dc2428b28 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Thu, 13 Aug 2026 17:14:12 +0200 Subject: [PATCH 6/8] docs(release): make editor notes user-facing --- .changeset/quiet-editors-glow.md | 5 ----- .changeset/tall-pillows-search.md | 4 ++-- CLAUDE.md | 10 ++++++++++ 3 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 .changeset/quiet-editors-glow.md diff --git a/.changeset/quiet-editors-glow.md b/.changeset/quiet-editors-glow.md deleted file mode 100644 index 17428f8..0000000 --- a/.changeset/quiet-editors-glow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"warpforge-desktop": patch ---- - -Keep editor changes manual until explicit save, style CodeMirror diagnostics and LSP tooltips for the dark UI, and parse TSX/JSX documents with their React language ids. diff --git a/.changeset/tall-pillows-search.md b/.changeset/tall-pillows-search.md index 1ae4236..a8ba35e 100644 --- a/.changeset/tall-pillows-search.md +++ b/.changeset/tall-pillows-search.md @@ -1,5 +1,5 @@ --- -"warpforge-desktop": patch +"warpforge": patch --- -Add double-Shift quick-open file palette, go-to-definition (Cmd/Ctrl-click or CmdB), and a daemon `file.search` RPC backing symbol lookup. Add optional CodeMirror LSP integration through daemon-managed language-server processes, including TypeScript 7's native `tsc --lsp --stdio`. +Code editing in Warpforge just got a major upgrade. The editor now brings intelligent language support into your workspace: jump from any symbol to its definition with Cmd/Ctrl-click or Cmd+B, see errors and warnings directly in your code, inspect documentation on hover, get completions as you type, find references, rename symbols, and format code. Double-Shift or Cmd/Ctrl+P opens any project file instantly, making large codebases much faster to navigate. Your work stays under your control too: edits are saved only when you explicitly press Save or Cmd/Ctrl+S. diff --git a/CLAUDE.md b/CLAUDE.md index 02feb1f..0337848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,6 +188,16 @@ it in the same commit as the change). Never hand-edit versions or `CHANGELOG.md` — the **Version release** workflow owns both. See `docs/RELEASING.md`. +Changeset text is customer-facing release-note copy. Write it for users, not +maintainers or changelog tooling: lead with outcome and product value, explain +how the feature helps and include a shortcut or action when useful. Use plain, +confident language and describe one coherent user-visible improvement per +changeset; combine related implementation commits when they ship as one +experience. Avoid internal names and implementation details such as RPCs, +packages, daemon processes, file paths, protocol names, or compiler flags. +Mention limitations only when they affect what users can do. Never claim +behavior the product does not provide. + Keep commits small and focused, not huge sweeping changes. Each commit should briefly describe the essence of what changed (one logical change per commit). From bce49055e14f9d55a7bebeeb3e5e988b50d3dfd6 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Thu, 13 Aug 2026 17:17:14 +0200 Subject: [PATCH 7/8] docs(release): mark editor upgrade minor --- .changeset/tall-pillows-search.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tall-pillows-search.md b/.changeset/tall-pillows-search.md index a8ba35e..8f67343 100644 --- a/.changeset/tall-pillows-search.md +++ b/.changeset/tall-pillows-search.md @@ -1,5 +1,5 @@ --- -"warpforge": patch +"warpforge": minor --- Code editing in Warpforge just got a major upgrade. The editor now brings intelligent language support into your workspace: jump from any symbol to its definition with Cmd/Ctrl-click or Cmd+B, see errors and warnings directly in your code, inspect documentation on hover, get completions as you type, find references, rename symbols, and format code. Double-Shift or Cmd/Ctrl+P opens any project file instantly, making large codebases much faster to navigate. Your work stays under your control too: edits are saved only when you explicitly press Save or Cmd/Ctrl+S. From 42c52d567041cde627842b423f46ce636b405af8 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Thu, 13 Aug 2026 17:31:31 +0200 Subject: [PATCH 8/8] fix(editor): resolve merged file props --- desktop/src/views/task-detail/FilesSurface.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/desktop/src/views/task-detail/FilesSurface.tsx b/desktop/src/views/task-detail/FilesSurface.tsx index bec48e3..7bca761 100644 --- a/desktop/src/views/task-detail/FilesSurface.tsx +++ b/desktop/src/views/task-detail/FilesSurface.tsx @@ -41,7 +41,6 @@ export function FilesSurface({ onSave, rootPath, onRefresh, - taskId, onGotoDefinition, onOpenSymbol, gotoLocation, @@ -60,7 +59,6 @@ export function FilesSurface({ onSave: (content: string) => void; rootPath?: string; onRefresh: () => void; - taskId: string; onGotoDefinition?: (query: string) => Promise; onOpenSymbol?: (path: string, line: number, column: number) => void; gotoLocation?: { path: string; line: number; column: number } | null;