diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index f56dae41f..5815f4071 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -828,6 +828,60 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { relativePath: target.relativePath }; } + case WS_METHODS.projectsReadFile: { + const body = stripRequestTag(request.body); + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: body.cwd, + relativePath: body.relativePath, + path, + }); + const MAX_READ_SIZE = 1_048_576; // 1MB + const fileStat = yield* fileSystem.stat(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to read file: ${String(cause)}`, + }), + ), + ); + if (fileStat.type !== "File") { + return yield* new RouteRequestError({ + message: `Path is not a file: ${target.relativePath}`, + }); + } + const sizeBytes = Number(fileStat.size); + if (sizeBytes > MAX_READ_SIZE) { + return yield* new RouteRequestError({ + message: `File is too large to display (${(sizeBytes / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 1MB.`, + }); + } + // Read raw bytes to detect binary files + const rawBytes = yield* fileSystem.readFile(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to read file: ${String(cause)}`, + }), + ), + ); + // Check for null bytes in the first 8KB to detect binary files + const checkLength = Math.min(rawBytes.length, 8192); + for (let i = 0; i < checkLength; i++) { + if (rawBytes[i] === 0) { + return yield* new RouteRequestError({ + message: `File appears to be binary and cannot be displayed: ${target.relativePath}`, + }); + } + } + const contents = new TextDecoder().decode(rawBytes); + return { + relativePath: target.relativePath, + contents, + sizeBytes, + truncated: false, + }; + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); diff --git a/apps/web/package.json b/apps/web/package.json index 71163d593..382aabf6d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,11 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@codemirror/language": "^6.12.3", + "@codemirror/language-data": "^6.5.2", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.40.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/src/codeViewerRouteSearch.ts b/apps/web/src/codeViewerRouteSearch.ts new file mode 100644 index 000000000..39dc83ef9 --- /dev/null +++ b/apps/web/src/codeViewerRouteSearch.ts @@ -0,0 +1,22 @@ +export interface CodeViewerRouteSearch { + codeViewer?: "1" | undefined; +} + +function isCodeViewerOpenValue(value: unknown): boolean { + return value === "1" || value === 1 || value === true; +} + +export function stripCodeViewerSearchParams>( + params: T, +): Omit { + const { codeViewer: _codeViewer, ...rest } = params; + return rest as Omit; +} + +export function parseCodeViewerRouteSearch(search: Record): CodeViewerRouteSearch { + const codeViewer = isCodeViewerOpenValue(search.codeViewer) ? "1" : undefined; + if (codeViewer) { + return { codeViewer }; + } + return {}; +} diff --git a/apps/web/src/codeViewerStore.ts b/apps/web/src/codeViewerStore.ts new file mode 100644 index 000000000..5bed20c26 --- /dev/null +++ b/apps/web/src/codeViewerStore.ts @@ -0,0 +1,61 @@ +import { create } from "zustand"; + +export interface CodeViewerTab { + cwd: string; + relativePath: string; + label: string; +} + +interface CodeViewerState { + tabs: CodeViewerTab[]; + activeTabPath: string | null; + openFile: (cwd: string, relativePath: string) => void; + closeTab: (relativePath: string) => void; + setActiveTab: (relativePath: string) => void; + closeAllTabs: () => void; +} + +function basenameOf(filePath: string): string { + const segments = filePath.split("/"); + return segments[segments.length - 1] ?? filePath; +} + +export const useCodeViewerStore = create((set) => ({ + tabs: [], + activeTabPath: null, + + openFile: (cwd, relativePath) => + set((state) => { + const existing = state.tabs.find((tab) => tab.relativePath === relativePath); + if (existing) { + return { activeTabPath: relativePath }; + } + const newTab: CodeViewerTab = { + cwd, + relativePath, + label: basenameOf(relativePath), + }; + return { + tabs: [...state.tabs, newTab], + activeTabPath: relativePath, + }; + }), + + closeTab: (relativePath) => + set((state) => { + const index = state.tabs.findIndex((tab) => tab.relativePath === relativePath); + if (index === -1) return state; + const nextTabs = state.tabs.filter((tab) => tab.relativePath !== relativePath); + let nextActive = state.activeTabPath; + if (state.activeTabPath === relativePath) { + // Activate the nearest tab + const nearestIndex = Math.min(index, nextTabs.length - 1); + nextActive = nextTabs[nearestIndex]?.relativePath ?? null; + } + return { tabs: nextTabs, activeTabPath: nextActive }; + }), + + setActiveTab: (relativePath) => set({ activeTabPath: relativePath }), + + closeAllTabs: () => set({ tabs: [], activeTabPath: null }), +})); diff --git a/apps/web/src/components/CodeMirrorViewer.tsx b/apps/web/src/components/CodeMirrorViewer.tsx new file mode 100644 index 000000000..2845e7ab2 --- /dev/null +++ b/apps/web/src/components/CodeMirrorViewer.tsx @@ -0,0 +1,149 @@ +import { EditorState, type Extension, Compartment } from "@codemirror/state"; +import { + EditorView, + lineNumbers, + highlightActiveLine, + highlightSpecialChars, +} from "@codemirror/view"; +import { + syntaxHighlighting, + defaultHighlightStyle, + LanguageDescription, +} from "@codemirror/language"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { memo, useEffect, useRef } from "react"; + +const themeCompartment = new Compartment(); +const languageCompartment = new Compartment(); + +const baseExtensions: Extension[] = [ + lineNumbers(), + highlightActiveLine(), + highlightSpecialChars(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + EditorView.editable.of(false), + EditorState.readOnly.of(true), + EditorView.theme({ + "&": { + height: "100%", + fontSize: "12px", + }, + ".cm-scroller": { + fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace", + overflow: "auto", + }, + ".cm-gutters": { + borderRight: "1px solid var(--border, #e5e7eb)", + backgroundColor: "transparent", + }, + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 8px 0 12px", + minWidth: "3ch", + color: "var(--muted-foreground, #6b7280)", + opacity: "0.5", + fontSize: "11px", + }, + }), +]; + +function getThemeExtension(resolvedTheme: "light" | "dark"): Extension { + return resolvedTheme === "dark" ? oneDark : []; +} + +async function loadLanguageExtension(filePath: string): Promise { + const languages = (await import("@codemirror/language-data")).languages; + const match = LanguageDescription.matchFilename(languages, filePath); + if (!match) return []; + const support = await match.load(); + return support; +} + +export const CodeMirrorViewer = memo(function CodeMirrorViewer(props: { + contents: string; + filePath: string; + resolvedTheme: "light" | "dark"; +}) { + const containerRef = useRef(null); + const viewRef = useRef(null); + const filePathRef = useRef(null); + + // Create editor on mount + useEffect(() => { + if (!containerRef.current) return; + + const state = EditorState.create({ + doc: props.contents, + extensions: [ + ...baseExtensions, + themeCompartment.of(getThemeExtension(props.resolvedTheme)), + languageCompartment.of([]), + ], + }); + + const view = new EditorView({ + state, + parent: containerRef.current, + }); + + viewRef.current = view; + + // Load language support asynchronously + void loadLanguageExtension(props.filePath).then((langExt) => { + if (viewRef.current === view) { + view.dispatch({ + effects: languageCompartment.reconfigure(langExt), + }); + } + }); + filePathRef.current = props.filePath; + + return () => { + view.destroy(); + viewRef.current = null; + }; + // Only re-create on mount/unmount — updates handled below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update contents when they change + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const currentDoc = view.state.doc.toString(); + if (currentDoc !== props.contents) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: props.contents }, + }); + } + }, [props.contents]); + + // Update theme when it changes + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: themeCompartment.reconfigure(getThemeExtension(props.resolvedTheme)), + }); + }, [props.resolvedTheme]); + + // Update language when file path changes + useEffect(() => { + if (filePathRef.current === props.filePath) return; + filePathRef.current = props.filePath; + + const view = viewRef.current; + if (!view) return; + + void loadLanguageExtension(props.filePath).then((langExt) => { + if (viewRef.current === view) { + view.dispatch({ + effects: languageCompartment.reconfigure(langExt), + }); + } + }); + }, [props.filePath]); + + return
; +}); diff --git a/apps/web/src/components/CodeViewerPanel.tsx b/apps/web/src/components/CodeViewerPanel.tsx new file mode 100644 index 000000000..f486c8af8 --- /dev/null +++ b/apps/web/src/components/CodeViewerPanel.tsx @@ -0,0 +1,155 @@ +import { useQuery } from "@tanstack/react-query"; +import { FileCodeIcon, XIcon } from "lucide-react"; +import { memo, useCallback } from "react"; + +import { useCodeViewerStore, type CodeViewerTab } from "~/codeViewerStore"; +import { useTheme } from "~/hooks/useTheme"; +import { projectReadFileQueryOptions } from "~/lib/projectReactQuery"; +import { cn } from "~/lib/utils"; +import { CodeMirrorViewer } from "./CodeMirrorViewer"; +import { type DiffPanelMode, DiffPanelShell, DiffPanelLoadingState } from "./DiffPanelShell"; + +export type CodeViewerPanelMode = DiffPanelMode; + +function CodeViewerTabStrip(props: { + tabs: CodeViewerTab[]; + activeTabPath: string | null; + onSelectTab: (relativePath: string) => void; + onCloseTab: (relativePath: string) => void; +}) { + return ( +
+ {props.tabs.map((tab) => { + const isActive = tab.relativePath === props.activeTabPath; + return ( +
+ + +
+ ); + })} +
+ ); +} + +const CodeViewerFileContent = memo(function CodeViewerFileContent(props: { + cwd: string; + relativePath: string; + resolvedTheme: "light" | "dark"; +}) { + const query = useQuery( + projectReadFileQueryOptions({ + cwd: props.cwd, + relativePath: props.relativePath, + }), + ); + + if (query.isLoading) { + return ; + } + + if (query.isError) { + const message = query.error instanceof Error ? query.error.message : "Failed to load file."; + return ( +
+ {message} +
+ ); + } + + if (!query.data?.contents && query.data?.contents !== "") { + return ( +
+ No content available. +
+ ); + } + + return ( +
+ {query.data.truncated && ( +
+ File is larger than 1MB. Showing truncated content. +
+ )} + +
+ ); +}); + +interface CodeViewerPanelProps { + mode?: CodeViewerPanelMode; +} + +export default function CodeViewerPanel({ mode = "inline" }: CodeViewerPanelProps) { + const { resolvedTheme } = useTheme(); + const tabs = useCodeViewerStore((state) => state.tabs); + const activeTabPath = useCodeViewerStore((state) => state.activeTabPath); + const setActiveTab = useCodeViewerStore((state) => state.setActiveTab); + const closeTab = useCodeViewerStore((state) => state.closeTab); + + const activeTab = tabs.find((tab) => tab.relativePath === activeTabPath); + + const onSelectTab = useCallback( + (relativePath: string) => setActiveTab(relativePath), + [setActiveTab], + ); + + const onCloseTab = useCallback((relativePath: string) => closeTab(relativePath), [closeTab]); + + const headerRow = ( + + ); + + return ( + + {!activeTab ? ( +
+ +

Click a file in the sidebar to view it here.

+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 913f455ad..ad6c7a2ea 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -690,9 +690,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions success: (count) => ({ title: count === 1 ? "Opened conflicted file" : "Opened conflicted files", description: - count === 1 - ? (conflictedFiles[0] ?? undefined) - : `${count} files opened in your editor.`, + count === 1 ? (conflictedFiles[0] ?? undefined) : `${count} files opened in your editor.`, data: threadToastData, }), error: (error) => ({ diff --git a/apps/web/src/components/WorkspaceFileTree.tsx b/apps/web/src/components/WorkspaceFileTree.tsx index 78fcaa456..9048fa7e4 100644 --- a/apps/web/src/components/WorkspaceFileTree.tsx +++ b/apps/web/src/components/WorkspaceFileTree.tsx @@ -2,6 +2,7 @@ import { type ProjectDirectoryEntry } from "@okcode/contracts"; import { useQuery } from "@tanstack/react-query"; import { ChevronRightIcon, FolderClosedIcon, FolderIcon, TriangleAlertIcon } from "lucide-react"; import { memo, useCallback, useState } from "react"; +import { useCodeViewerStore } from "~/codeViewerStore"; import { openInPreferredEditor } from "~/editorPreferences"; import { projectListDirectoryQueryOptions } from "~/lib/projectReactQuery"; import { cn } from "~/lib/utils"; @@ -27,27 +28,36 @@ export const WorkspaceFileTree = memo(function WorkspaceFileTree(props: { })); }, []); + const openFileInViewer = useCodeViewerStore((state) => state.openFile); + const openFile = useCallback( - (filePath: string) => { - const api = readNativeApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "File opening is unavailable.", + (filePath: string, event?: { metaKey?: boolean; ctrlKey?: boolean }) => { + // Cmd/Ctrl+click opens in external editor + if (event?.metaKey || event?.ctrlKey) { + const api = readNativeApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "File opening is unavailable.", + }); + return; + } + + const targetPath = resolvePathLinkTarget(filePath, props.cwd); + void openInPreferredEditor(api, targetPath).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }); }); return; } - const targetPath = resolvePathLinkTarget(filePath, props.cwd); - void openInPreferredEditor(api, targetPath).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }); + // Default click opens in built-in code viewer + openFileInViewer(props.cwd, filePath); }, - [props.cwd], + [props.cwd, openFileInViewer], ); return ( @@ -71,7 +81,7 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop expandedDirectories: Readonly>; resolvedTheme: "light" | "dark"; onToggleDirectory: (pathValue: string) => void; - onOpenFile: (pathValue: string) => void; + onOpenFile: (pathValue: string, event?: { metaKey?: boolean; ctrlKey?: boolean }) => void; }) { const query = useQuery( projectListDirectoryQueryOptions({ @@ -196,7 +206,7 @@ const WorkspaceFileRow = memo(function WorkspaceFileRow(props: { depth: number; entry: ProjectDirectoryEntry; resolvedTheme: "light" | "dark"; - onOpenFile: (pathValue: string) => void; + onOpenFile: (pathValue: string, event?: { metaKey?: boolean; ctrlKey?: boolean }) => void; }) { const leftPadding = TREE_ROW_LEFT_PADDING + props.depth * TREE_ROW_DEPTH_OFFSET; @@ -205,7 +215,9 @@ const WorkspaceFileRow = memo(function WorkspaceFileRow(props: { type="button" className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-accent/60" style={{ paddingLeft: `${leftPadding}px` }} - onClick={() => props.onOpenFile(props.entry.path)} + onClick={(event) => + props.onOpenFile(props.entry.path, { metaKey: event.metaKey, ctrlKey: event.ctrlKey }) + } title={props.entry.path} >