Skip to content

Commit e7ddb24

Browse files
authored
Merge pull request #23 from OpenKnots/okcode/internal-code-viewer
Promote code viewer to a full-page overlay
2 parents ea60290 + 76b6f09 commit e7ddb24

7 files changed

Lines changed: 214 additions & 256 deletions

File tree

apps/web/src/codeViewerStore.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,26 @@ export interface CodeViewerTab {
66
label: string;
77
}
88

9+
export interface CodeViewerPendingContext {
10+
filePath: string;
11+
fromLine: number;
12+
toLine: number;
13+
}
14+
915
interface CodeViewerState {
16+
isOpen: boolean;
1017
tabs: CodeViewerTab[];
1118
activeTabPath: string | null;
19+
pendingContext: CodeViewerPendingContext | null;
20+
open: () => void;
21+
close: () => void;
22+
toggle: () => void;
1223
openFile: (cwd: string, relativePath: string) => void;
1324
closeTab: (relativePath: string) => void;
1425
setActiveTab: (relativePath: string) => void;
1526
closeAllTabs: () => void;
27+
setPendingContext: (ctx: CodeViewerPendingContext) => void;
28+
clearPendingContext: () => void;
1629
}
1730

1831
function basenameOf(filePath: string): string {
@@ -21,21 +34,34 @@ function basenameOf(filePath: string): string {
2134
}
2235

2336
export const useCodeViewerStore = create<CodeViewerState>((set) => ({
37+
isOpen: false,
2438
tabs: [],
2539
activeTabPath: null,
40+
pendingContext: null,
41+
42+
open: () => set({ isOpen: true }),
43+
close: () => set({ isOpen: false, tabs: [], activeTabPath: null }),
44+
toggle: () =>
45+
set((state) => {
46+
if (state.isOpen) {
47+
return { isOpen: false, tabs: [], activeTabPath: null };
48+
}
49+
return { isOpen: true };
50+
}),
2651

2752
openFile: (cwd, relativePath) =>
2853
set((state) => {
2954
const existing = state.tabs.find((tab) => tab.relativePath === relativePath);
3055
if (existing) {
31-
return { activeTabPath: relativePath };
56+
return { isOpen: true, activeTabPath: relativePath };
3257
}
3358
const newTab: CodeViewerTab = {
3459
cwd,
3560
relativePath,
3661
label: basenameOf(relativePath),
3762
};
3863
return {
64+
isOpen: true,
3965
tabs: [...state.tabs, newTab],
4066
activeTabPath: relativePath,
4167
};
@@ -52,10 +78,17 @@ export const useCodeViewerStore = create<CodeViewerState>((set) => ({
5278
const nearestIndex = Math.min(index, nextTabs.length - 1);
5379
nextActive = nextTabs[nearestIndex]?.relativePath ?? null;
5480
}
81+
// If no tabs left, close the viewer
82+
if (nextTabs.length === 0) {
83+
return { isOpen: false, tabs: [], activeTabPath: null };
84+
}
5585
return { tabs: nextTabs, activeTabPath: nextActive };
5686
}),
5787

5888
setActiveTab: (relativePath) => set({ activeTabPath: relativePath }),
5989

60-
closeAllTabs: () => set({ tabs: [], activeTabPath: null }),
90+
closeAllTabs: () => set({ isOpen: false, tabs: [], activeTabPath: null }),
91+
92+
setPendingContext: (ctx) => set({ pendingContext: ctx }),
93+
clearPendingContext: () => set({ pendingContext: null }),
6194
}));

apps/web/src/components/ChatView.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
type ServerProviderStatus,
1616
type ThreadId,
1717
type TurnId,
18-
type EditorId,
1918
type KeybindingCommand,
2019
OrchestrationThreadActivity,
2120
ProviderInteractionMode,
@@ -153,6 +152,7 @@ import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./Compose
153152
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
154153
import { MessagesTimeline } from "./chat/MessagesTimeline";
155154
import { ChatHeader } from "./chat/ChatHeader";
155+
import { useCodeViewerStore } from "~/codeViewerStore";
156156
import { PreviewPanel } from "./PreviewPanel";
157157
import { ContextWindowMeter } from "./chat/ContextWindowMeter";
158158
import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
@@ -196,7 +196,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
196196
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
197197
const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
198198
const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
199-
const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
200199
const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
201200
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
202201

@@ -1149,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
11491148
[nonPersistedComposerImageIds],
11501149
);
11511150
const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
1152-
const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
11531151
const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
11541152
const activeProviderStatus = useMemo(
11551153
() => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
@@ -1200,6 +1198,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
12001198
});
12011199
}, [diffOpen, navigate, threadId]);
12021200

1201+
const toggleCodeViewer = useCodeViewerStore((state) => state.toggle);
1202+
const pendingContext = useCodeViewerStore((state) => state.pendingContext);
1203+
const clearPendingContext = useCodeViewerStore((state) => state.clearPendingContext);
1204+
1205+
// When Cmd+L is pressed in the code viewer, insert the @file:lines mention into the composer
1206+
useEffect(() => {
1207+
if (!pendingContext) return;
1208+
const { filePath, fromLine, toLine } = pendingContext;
1209+
const mention =
1210+
fromLine === toLine ? `@${filePath}:L${fromLine}` : `@${filePath}:L${fromLine}-L${toLine}`;
1211+
const currentPrompt = prompt;
1212+
const separator = currentPrompt.length > 0 && !currentPrompt.endsWith(" ") ? " " : "";
1213+
setPrompt(`${currentPrompt}${separator}${mention} `);
1214+
clearPendingContext();
1215+
}, [pendingContext, clearPendingContext, prompt, setPrompt]);
1216+
12031217
const envLocked = Boolean(
12041218
activeThread &&
12051219
(activeThread.messages.length > 0 ||
@@ -3842,7 +3856,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
38423856
activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
38433857
}
38443858
keybindings={keybindings}
3845-
availableEditors={availableEditors}
38463859
terminalAvailable={activeProject !== undefined}
38473860
terminalOpen={terminalState.terminalOpen}
38483861
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
@@ -3862,6 +3875,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
38623875
onToggleDiff={onToggleDiff}
38633876
onTogglePreview={() => togglePreviewOpen(activeThread.id)}
38643877
onTogglePreviewLayout={() => togglePreviewLayout(activeThread.id)}
3878+
onToggleCodeViewer={toggleCodeViewer}
38653879
/>
38663880
</header>
38673881

apps/web/src/components/CodeMirrorViewer.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
lineNumbers,
55
highlightActiveLine,
66
highlightSpecialChars,
7+
keymap,
78
} from "@codemirror/view";
89
import {
910
syntaxHighlighting,
@@ -12,9 +13,17 @@ import {
1213
} from "@codemirror/language";
1314
import { oneDark } from "@codemirror/theme-one-dark";
1415
import { memo, useEffect, useRef } from "react";
16+
import { isMacPlatform } from "~/lib/utils";
17+
18+
export interface CodeContextSelection {
19+
filePath: string;
20+
fromLine: number;
21+
toLine: number;
22+
}
1523

1624
const themeCompartment = new Compartment();
1725
const languageCompartment = new Compartment();
26+
const keymapCompartment = new Compartment();
1827

1928
const baseExtensions: Extension[] = [
2029
lineNumbers(),
@@ -50,6 +59,9 @@ const baseExtensions: Extension[] = [
5059
".cm-activeLineGutter": {
5160
backgroundColor: "transparent",
5261
},
62+
".cm-selectionBackground": {
63+
backgroundColor: "color-mix(in srgb, var(--primary) 25%, transparent) !important",
64+
},
5365
}),
5466
];
5567

@@ -77,25 +89,52 @@ async function loadLanguageExtension(filePath: string): Promise<Extension> {
7789
return support;
7890
}
7991

92+
function buildAddContextKeymap(
93+
filePath: string,
94+
onAddContext: (ctx: CodeContextSelection) => void,
95+
): Extension {
96+
return keymap.of([
97+
{
98+
key: isMacPlatform(navigator.platform) ? "Mod-l" : "Ctrl-l",
99+
run: (view) => {
100+
const { from, to } = view.state.selection.main;
101+
if (from === to) return false; // No selection
102+
const fromLine = view.state.doc.lineAt(from).number;
103+
const toLine = view.state.doc.lineAt(to).number;
104+
onAddContext({ filePath, fromLine, toLine });
105+
return true;
106+
},
107+
},
108+
]);
109+
}
110+
80111
export const CodeMirrorViewer = memo(function CodeMirrorViewer(props: {
81112
contents: string;
82113
filePath: string;
83114
resolvedTheme: "light" | "dark";
115+
onAddContext?: (ctx: CodeContextSelection) => void;
84116
}) {
85117
const containerRef = useRef<HTMLDivElement>(null);
86118
const viewRef = useRef<EditorView | null>(null);
87119
const filePathRef = useRef<string | null>(null);
120+
const onAddContextRef = useRef(props.onAddContext);
121+
onAddContextRef.current = props.onAddContext;
88122

89123
// Create editor on mount
90124
useEffect(() => {
91125
if (!containerRef.current) return;
92126

127+
const addContextKeymap = buildAddContextKeymap(props.filePath, (ctx) => {
128+
onAddContextRef.current?.(ctx);
129+
});
130+
93131
const state = EditorState.create({
94132
doc: props.contents,
95133
extensions: [
96134
...baseExtensions,
97135
themeCompartment.of(getThemeExtension(props.resolvedTheme)),
98136
languageCompartment.of([]),
137+
keymapCompartment.of(addContextKeymap),
99138
],
100139
});
101140

@@ -147,7 +186,7 @@ export const CodeMirrorViewer = memo(function CodeMirrorViewer(props: {
147186
});
148187
}, [props.resolvedTheme]);
149188

150-
// Update language when file path changes
189+
// Update language and keymap when file path changes
151190
useEffect(() => {
152191
if (filePathRef.current === props.filePath) return;
153192
filePathRef.current = props.filePath;
@@ -162,6 +201,13 @@ export const CodeMirrorViewer = memo(function CodeMirrorViewer(props: {
162201
});
163202
}
164203
});
204+
205+
const addContextKeymap = buildAddContextKeymap(props.filePath, (ctx) => {
206+
onAddContextRef.current?.(ctx);
207+
});
208+
view.dispatch({
209+
effects: keymapCompartment.reconfigure(addContextKeymap),
210+
});
165211
}, [props.filePath]);
166212

167213
return <div ref={containerRef} className="h-full min-h-0 overflow-hidden" />;

0 commit comments

Comments
 (0)