Skip to content

Commit 4a7fe84

Browse files
authored
Merge pull request #4 from OpenKnots/okcode/custom-code-editor
Add built-in workspace code viewer
2 parents 5420747 + b63881e commit 4a7fe84

15 files changed

Lines changed: 806 additions & 67 deletions

apps/server/src/wsServer.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,60 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
828828
return { relativePath: target.relativePath };
829829
}
830830

831+
case WS_METHODS.projectsReadFile: {
832+
const body = stripRequestTag(request.body);
833+
const target = yield* resolveWorkspaceWritePath({
834+
workspaceRoot: body.cwd,
835+
relativePath: body.relativePath,
836+
path,
837+
});
838+
const MAX_READ_SIZE = 1_048_576; // 1MB
839+
const fileStat = yield* fileSystem.stat(target.absolutePath).pipe(
840+
Effect.mapError(
841+
(cause) =>
842+
new RouteRequestError({
843+
message: `Failed to read file: ${String(cause)}`,
844+
}),
845+
),
846+
);
847+
if (fileStat.type !== "File") {
848+
return yield* new RouteRequestError({
849+
message: `Path is not a file: ${target.relativePath}`,
850+
});
851+
}
852+
const sizeBytes = Number(fileStat.size);
853+
if (sizeBytes > MAX_READ_SIZE) {
854+
return yield* new RouteRequestError({
855+
message: `File is too large to display (${(sizeBytes / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 1MB.`,
856+
});
857+
}
858+
// Read raw bytes to detect binary files
859+
const rawBytes = yield* fileSystem.readFile(target.absolutePath).pipe(
860+
Effect.mapError(
861+
(cause) =>
862+
new RouteRequestError({
863+
message: `Failed to read file: ${String(cause)}`,
864+
}),
865+
),
866+
);
867+
// Check for null bytes in the first 8KB to detect binary files
868+
const checkLength = Math.min(rawBytes.length, 8192);
869+
for (let i = 0; i < checkLength; i++) {
870+
if (rawBytes[i] === 0) {
871+
return yield* new RouteRequestError({
872+
message: `File appears to be binary and cannot be displayed: ${target.relativePath}`,
873+
});
874+
}
875+
}
876+
const contents = new TextDecoder().decode(rawBytes);
877+
return {
878+
relativePath: target.relativePath,
879+
contents,
880+
sizeBytes,
881+
truncated: false,
882+
};
883+
}
884+
831885
case WS_METHODS.shellOpenInEditor: {
832886
const body = stripRequestTag(request.body);
833887
return yield* openInEditor(body);

apps/web/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
},
1616
"dependencies": {
1717
"@base-ui/react": "^1.2.0",
18+
"@codemirror/language": "^6.12.3",
19+
"@codemirror/language-data": "^6.5.2",
20+
"@codemirror/state": "^6.6.0",
21+
"@codemirror/theme-one-dark": "^6.1.3",
22+
"@codemirror/view": "^6.40.0",
1823
"@dnd-kit/core": "^6.3.1",
1924
"@dnd-kit/modifiers": "^9.0.0",
2025
"@dnd-kit/sortable": "^10.0.0",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface CodeViewerRouteSearch {
2+
codeViewer?: "1" | undefined;
3+
}
4+
5+
function isCodeViewerOpenValue(value: unknown): boolean {
6+
return value === "1" || value === 1 || value === true;
7+
}
8+
9+
export function stripCodeViewerSearchParams<T extends Record<string, unknown>>(
10+
params: T,
11+
): Omit<T, "codeViewer"> {
12+
const { codeViewer: _codeViewer, ...rest } = params;
13+
return rest as Omit<T, "codeViewer">;
14+
}
15+
16+
export function parseCodeViewerRouteSearch(search: Record<string, unknown>): CodeViewerRouteSearch {
17+
const codeViewer = isCodeViewerOpenValue(search.codeViewer) ? "1" : undefined;
18+
if (codeViewer) {
19+
return { codeViewer };
20+
}
21+
return {};
22+
}

apps/web/src/codeViewerStore.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { create } from "zustand";
2+
3+
export interface CodeViewerTab {
4+
cwd: string;
5+
relativePath: string;
6+
label: string;
7+
}
8+
9+
interface CodeViewerState {
10+
tabs: CodeViewerTab[];
11+
activeTabPath: string | null;
12+
openFile: (cwd: string, relativePath: string) => void;
13+
closeTab: (relativePath: string) => void;
14+
setActiveTab: (relativePath: string) => void;
15+
closeAllTabs: () => void;
16+
}
17+
18+
function basenameOf(filePath: string): string {
19+
const segments = filePath.split("/");
20+
return segments[segments.length - 1] ?? filePath;
21+
}
22+
23+
export const useCodeViewerStore = create<CodeViewerState>((set) => ({
24+
tabs: [],
25+
activeTabPath: null,
26+
27+
openFile: (cwd, relativePath) =>
28+
set((state) => {
29+
const existing = state.tabs.find((tab) => tab.relativePath === relativePath);
30+
if (existing) {
31+
return { activeTabPath: relativePath };
32+
}
33+
const newTab: CodeViewerTab = {
34+
cwd,
35+
relativePath,
36+
label: basenameOf(relativePath),
37+
};
38+
return {
39+
tabs: [...state.tabs, newTab],
40+
activeTabPath: relativePath,
41+
};
42+
}),
43+
44+
closeTab: (relativePath) =>
45+
set((state) => {
46+
const index = state.tabs.findIndex((tab) => tab.relativePath === relativePath);
47+
if (index === -1) return state;
48+
const nextTabs = state.tabs.filter((tab) => tab.relativePath !== relativePath);
49+
let nextActive = state.activeTabPath;
50+
if (state.activeTabPath === relativePath) {
51+
// Activate the nearest tab
52+
const nearestIndex = Math.min(index, nextTabs.length - 1);
53+
nextActive = nextTabs[nearestIndex]?.relativePath ?? null;
54+
}
55+
return { tabs: nextTabs, activeTabPath: nextActive };
56+
}),
57+
58+
setActiveTab: (relativePath) => set({ activeTabPath: relativePath }),
59+
60+
closeAllTabs: () => set({ tabs: [], activeTabPath: null }),
61+
}));
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { EditorState, type Extension, Compartment } from "@codemirror/state";
2+
import {
3+
EditorView,
4+
lineNumbers,
5+
highlightActiveLine,
6+
highlightSpecialChars,
7+
} from "@codemirror/view";
8+
import {
9+
syntaxHighlighting,
10+
defaultHighlightStyle,
11+
LanguageDescription,
12+
} from "@codemirror/language";
13+
import { oneDark } from "@codemirror/theme-one-dark";
14+
import { memo, useEffect, useRef } from "react";
15+
16+
const themeCompartment = new Compartment();
17+
const languageCompartment = new Compartment();
18+
19+
const baseExtensions: Extension[] = [
20+
lineNumbers(),
21+
highlightActiveLine(),
22+
highlightSpecialChars(),
23+
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
24+
EditorView.editable.of(false),
25+
EditorState.readOnly.of(true),
26+
EditorView.theme({
27+
"&": {
28+
height: "100%",
29+
fontSize: "12px",
30+
},
31+
".cm-scroller": {
32+
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
33+
overflow: "auto",
34+
},
35+
".cm-gutters": {
36+
borderRight: "1px solid var(--border, #e5e7eb)",
37+
backgroundColor: "transparent",
38+
},
39+
".cm-lineNumbers .cm-gutterElement": {
40+
padding: "0 8px 0 12px",
41+
minWidth: "3ch",
42+
color: "var(--muted-foreground, #6b7280)",
43+
opacity: "0.5",
44+
fontSize: "11px",
45+
},
46+
}),
47+
];
48+
49+
function getThemeExtension(resolvedTheme: "light" | "dark"): Extension {
50+
return resolvedTheme === "dark" ? oneDark : [];
51+
}
52+
53+
async function loadLanguageExtension(filePath: string): Promise<Extension> {
54+
const languages = (await import("@codemirror/language-data")).languages;
55+
const match = LanguageDescription.matchFilename(languages, filePath);
56+
if (!match) return [];
57+
const support = await match.load();
58+
return support;
59+
}
60+
61+
export const CodeMirrorViewer = memo(function CodeMirrorViewer(props: {
62+
contents: string;
63+
filePath: string;
64+
resolvedTheme: "light" | "dark";
65+
}) {
66+
const containerRef = useRef<HTMLDivElement>(null);
67+
const viewRef = useRef<EditorView | null>(null);
68+
const filePathRef = useRef<string | null>(null);
69+
70+
// Create editor on mount
71+
useEffect(() => {
72+
if (!containerRef.current) return;
73+
74+
const state = EditorState.create({
75+
doc: props.contents,
76+
extensions: [
77+
...baseExtensions,
78+
themeCompartment.of(getThemeExtension(props.resolvedTheme)),
79+
languageCompartment.of([]),
80+
],
81+
});
82+
83+
const view = new EditorView({
84+
state,
85+
parent: containerRef.current,
86+
});
87+
88+
viewRef.current = view;
89+
90+
// Load language support asynchronously
91+
void loadLanguageExtension(props.filePath).then((langExt) => {
92+
if (viewRef.current === view) {
93+
view.dispatch({
94+
effects: languageCompartment.reconfigure(langExt),
95+
});
96+
}
97+
});
98+
filePathRef.current = props.filePath;
99+
100+
return () => {
101+
view.destroy();
102+
viewRef.current = null;
103+
};
104+
// Only re-create on mount/unmount — updates handled below
105+
// eslint-disable-next-line react-hooks/exhaustive-deps
106+
}, []);
107+
108+
// Update contents when they change
109+
useEffect(() => {
110+
const view = viewRef.current;
111+
if (!view) return;
112+
113+
const currentDoc = view.state.doc.toString();
114+
if (currentDoc !== props.contents) {
115+
view.dispatch({
116+
changes: { from: 0, to: view.state.doc.length, insert: props.contents },
117+
});
118+
}
119+
}, [props.contents]);
120+
121+
// Update theme when it changes
122+
useEffect(() => {
123+
const view = viewRef.current;
124+
if (!view) return;
125+
126+
view.dispatch({
127+
effects: themeCompartment.reconfigure(getThemeExtension(props.resolvedTheme)),
128+
});
129+
}, [props.resolvedTheme]);
130+
131+
// Update language when file path changes
132+
useEffect(() => {
133+
if (filePathRef.current === props.filePath) return;
134+
filePathRef.current = props.filePath;
135+
136+
const view = viewRef.current;
137+
if (!view) return;
138+
139+
void loadLanguageExtension(props.filePath).then((langExt) => {
140+
if (viewRef.current === view) {
141+
view.dispatch({
142+
effects: languageCompartment.reconfigure(langExt),
143+
});
144+
}
145+
});
146+
}, [props.filePath]);
147+
148+
return <div ref={containerRef} className="h-full min-h-0 overflow-hidden" />;
149+
});

0 commit comments

Comments
 (0)