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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/codeViewerRouteSearch.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Record<string, unknown>>(
params: T,
): Omit<T, "codeViewer"> {
const { codeViewer: _codeViewer, ...rest } = params;
return rest as Omit<T, "codeViewer">;
}

export function parseCodeViewerRouteSearch(search: Record<string, unknown>): CodeViewerRouteSearch {
const codeViewer = isCodeViewerOpenValue(search.codeViewer) ? "1" : undefined;
if (codeViewer) {
return { codeViewer };
}
return {};
}
61 changes: 61 additions & 0 deletions apps/web/src/codeViewerStore.ts
Original file line number Diff line number Diff line change
@@ -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<CodeViewerState>((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 }),
}));
149 changes: 149 additions & 0 deletions apps/web/src/components/CodeMirrorViewer.tsx
Original file line number Diff line number Diff line change
@@ -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<Extension> {
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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const filePathRef = useRef<string | null>(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 <div ref={containerRef} className="h-full min-h-0 overflow-hidden" />;
});
Loading
Loading