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
37 changes: 35 additions & 2 deletions apps/web/src/codeViewerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@ export interface CodeViewerTab {
label: string;
}

export interface CodeViewerPendingContext {
filePath: string;
fromLine: number;
toLine: number;
}

interface CodeViewerState {
isOpen: boolean;
tabs: CodeViewerTab[];
activeTabPath: string | null;
pendingContext: CodeViewerPendingContext | null;
open: () => void;
close: () => void;
toggle: () => void;
openFile: (cwd: string, relativePath: string) => void;
closeTab: (relativePath: string) => void;
setActiveTab: (relativePath: string) => void;
closeAllTabs: () => void;
setPendingContext: (ctx: CodeViewerPendingContext) => void;
clearPendingContext: () => void;
}

function basenameOf(filePath: string): string {
Expand All @@ -21,21 +34,34 @@ function basenameOf(filePath: string): string {
}

export const useCodeViewerStore = create<CodeViewerState>((set) => ({
isOpen: false,
tabs: [],
activeTabPath: null,
pendingContext: null,

open: () => set({ isOpen: true }),
close: () => set({ isOpen: false, tabs: [], activeTabPath: null }),
toggle: () =>
set((state) => {
if (state.isOpen) {
return { isOpen: false, tabs: [], activeTabPath: null };
}
return { isOpen: true };
}),

openFile: (cwd, relativePath) =>
set((state) => {
const existing = state.tabs.find((tab) => tab.relativePath === relativePath);
if (existing) {
return { activeTabPath: relativePath };
return { isOpen: true, activeTabPath: relativePath };
}
const newTab: CodeViewerTab = {
cwd,
relativePath,
label: basenameOf(relativePath),
};
return {
isOpen: true,
tabs: [...state.tabs, newTab],
activeTabPath: relativePath,
};
Expand All @@ -52,10 +78,17 @@ export const useCodeViewerStore = create<CodeViewerState>((set) => ({
const nearestIndex = Math.min(index, nextTabs.length - 1);
nextActive = nextTabs[nearestIndex]?.relativePath ?? null;
}
// If no tabs left, close the viewer
if (nextTabs.length === 0) {
return { isOpen: false, tabs: [], activeTabPath: null };
}
return { tabs: nextTabs, activeTabPath: nextActive };
}),

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

closeAllTabs: () => set({ tabs: [], activeTabPath: null }),
closeAllTabs: () => set({ isOpen: false, tabs: [], activeTabPath: null }),

setPendingContext: (ctx) => set({ pendingContext: ctx }),
clearPendingContext: () => set({ pendingContext: null }),
}));
22 changes: 18 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
type ServerProviderStatus,
type ThreadId,
type TurnId,
type EditorId,
type KeybindingCommand,
OrchestrationThreadActivity,
ProviderInteractionMode,
Expand Down Expand Up @@ -153,6 +152,7 @@ import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./Compose
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { ChatHeader } from "./chat/ChatHeader";
import { useCodeViewerStore } from "~/codeViewerStore";
import { PreviewPanel } from "./PreviewPanel";
import { ContextWindowMeter } from "./chat/ContextWindowMeter";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
Expand Down Expand Up @@ -196,7 +196,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};

Expand Down Expand Up @@ -1149,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
[nonPersistedComposerImageIds],
);
const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
const activeProviderStatus = useMemo(
() => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
Expand Down Expand Up @@ -1200,6 +1198,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
});
}, [diffOpen, navigate, threadId]);

const toggleCodeViewer = useCodeViewerStore((state) => state.toggle);
const pendingContext = useCodeViewerStore((state) => state.pendingContext);
const clearPendingContext = useCodeViewerStore((state) => state.clearPendingContext);

// When Cmd+L is pressed in the code viewer, insert the @file:lines mention into the composer
useEffect(() => {
if (!pendingContext) return;
const { filePath, fromLine, toLine } = pendingContext;
const mention =
fromLine === toLine ? `@${filePath}:L${fromLine}` : `@${filePath}:L${fromLine}-L${toLine}`;
const currentPrompt = prompt;
const separator = currentPrompt.length > 0 && !currentPrompt.endsWith(" ") ? " " : "";
setPrompt(`${currentPrompt}${separator}${mention} `);
clearPendingContext();
}, [pendingContext, clearPendingContext, prompt, setPrompt]);

const envLocked = Boolean(
activeThread &&
(activeThread.messages.length > 0 ||
Expand Down Expand Up @@ -3842,7 +3856,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
}
keybindings={keybindings}
availableEditors={availableEditors}
terminalAvailable={activeProject !== undefined}
terminalOpen={terminalState.terminalOpen}
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
Expand All @@ -3862,6 +3875,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onToggleDiff={onToggleDiff}
onTogglePreview={() => togglePreviewOpen(activeThread.id)}
onTogglePreviewLayout={() => togglePreviewLayout(activeThread.id)}
onToggleCodeViewer={toggleCodeViewer}
/>
</header>

Expand Down
48 changes: 47 additions & 1 deletion apps/web/src/components/CodeMirrorViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
lineNumbers,
highlightActiveLine,
highlightSpecialChars,
keymap,
} from "@codemirror/view";
import {
syntaxHighlighting,
Expand All @@ -12,9 +13,17 @@ import {
} from "@codemirror/language";
import { oneDark } from "@codemirror/theme-one-dark";
import { memo, useEffect, useRef } from "react";
import { isMacPlatform } from "~/lib/utils";

export interface CodeContextSelection {
filePath: string;
fromLine: number;
toLine: number;
}

const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
const keymapCompartment = new Compartment();

const baseExtensions: Extension[] = [
lineNumbers(),
Expand Down Expand Up @@ -50,6 +59,9 @@ const baseExtensions: Extension[] = [
".cm-activeLineGutter": {
backgroundColor: "transparent",
},
".cm-selectionBackground": {
backgroundColor: "color-mix(in srgb, var(--primary) 25%, transparent) !important",
},
}),
];

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

function buildAddContextKeymap(
filePath: string,
onAddContext: (ctx: CodeContextSelection) => void,
): Extension {
return keymap.of([
{
key: isMacPlatform(navigator.platform) ? "Mod-l" : "Ctrl-l",
run: (view) => {
const { from, to } = view.state.selection.main;
if (from === to) return false; // No selection
const fromLine = view.state.doc.lineAt(from).number;
const toLine = view.state.doc.lineAt(to).number;
onAddContext({ filePath, fromLine, toLine });
return true;
},
},
]);
}

export const CodeMirrorViewer = memo(function CodeMirrorViewer(props: {
contents: string;
filePath: string;
resolvedTheme: "light" | "dark";
onAddContext?: (ctx: CodeContextSelection) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const filePathRef = useRef<string | null>(null);
const onAddContextRef = useRef(props.onAddContext);
onAddContextRef.current = props.onAddContext;

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

const addContextKeymap = buildAddContextKeymap(props.filePath, (ctx) => {
onAddContextRef.current?.(ctx);
});

const state = EditorState.create({
doc: props.contents,
extensions: [
...baseExtensions,
themeCompartment.of(getThemeExtension(props.resolvedTheme)),
languageCompartment.of([]),
keymapCompartment.of(addContextKeymap),
],
});

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

// Update language when file path changes
// Update language and keymap when file path changes
useEffect(() => {
if (filePathRef.current === props.filePath) return;
filePathRef.current = props.filePath;
Expand All @@ -162,6 +201,13 @@ export const CodeMirrorViewer = memo(function CodeMirrorViewer(props: {
});
}
});

const addContextKeymap = buildAddContextKeymap(props.filePath, (ctx) => {
onAddContextRef.current?.(ctx);
});
view.dispatch({
effects: keymapCompartment.reconfigure(addContextKeymap),
});
}, [props.filePath]);

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