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
5 changes: 3 additions & 2 deletions apps/web/src/components/RightPanelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ const TABS: readonly {
{ id: "diffs", label: "Diffs", icon: GitCompareIcon },
];

export const RightPanelHeader = memo(function RightPanelHeader() {
export const RightPanelHeader = memo(function RightPanelHeader(props: { hasDiffs: boolean }) {
const activeTab = useRightPanelStore((s) => s.activeTab);
const setActiveTab = useRightPanelStore((s) => s.setActiveTab);
const close = useRightPanelStore((s) => s.close);
const visibleTabs = props.hasDiffs ? TABS : TABS.filter((tab) => tab.id !== "diffs");

return (
<div
Expand All @@ -27,7 +28,7 @@ export const RightPanelHeader = memo(function RightPanelHeader() {
)}
>
<div className="flex items-center gap-0.5 [-webkit-app-region:no-drag]">
{TABS.map((tab) => {
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/pr-review/pr-review-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { FileDiffMetadata } from "@pierre/diffs/react";

import { resolveFileDiffLanguage } from "./pr-review-utils";

describe("resolveFileDiffLanguage", () => {
it("normalizes common VS Code language ids into diff highlighter languages", () => {
expect(resolveFileDiffLanguage({ lang: "typescriptreact" } as FileDiffMetadata)).toBe("tsx");
expect(resolveFileDiffLanguage({ lang: "javascriptreact" } as FileDiffMetadata)).toBe("jsx");
expect(resolveFileDiffLanguage({ lang: "manifest-yaml" } as FileDiffMetadata)).toBe("yaml");
expect(resolveFileDiffLanguage({ lang: "esphome" } as FileDiffMetadata)).toBe("yaml");
expect(resolveFileDiffLanguage({ lang: "django-html" } as FileDiffMetadata)).toBe("html");
expect(resolveFileDiffLanguage({ lang: "cfmhtml" } as FileDiffMetadata)).toBe("html");
expect(resolveFileDiffLanguage({ lang: "restructuredtext" } as FileDiffMetadata)).toBe("rst");
expect(resolveFileDiffLanguage({ lang: "json-tmlanguage" } as FileDiffMetadata)).toBe("json");
expect(resolveFileDiffLanguage({ lang: "plaintext" } as FileDiffMetadata)).toBe("text");
expect(resolveFileDiffLanguage({ lang: "go.mod" } as FileDiffMetadata)).toBe("go");
expect(resolveFileDiffLanguage({ lang: "swagger" } as FileDiffMetadata)).toBe("yaml");
});

it("infers normalized languages from file paths", () => {
expect(resolveFileDiffLanguage({ name: "checkbox.tsx" } as FileDiffMetadata)).toBe("tsx");
expect(resolveFileDiffLanguage({ name: "entrypoint.sh" } as FileDiffMetadata)).toBe(
"shellscript",
);
expect(resolveFileDiffLanguage({ name: "config.yml" } as FileDiffMetadata)).toBe("yaml");
});
});
2 changes: 1 addition & 1 deletion apps/web/src/components/pr-review/pr-review-utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ describe("withInferredFileDiffLanguage", () => {
isPartial: true,
} as never;

expect(withInferredFileDiffLanguage(fileDiff).lang).toBe("typescriptreact");
expect(withInferredFileDiffLanguage(fileDiff).lang).toBe("tsx");
});
});
5 changes: 3 additions & 2 deletions apps/web/src/components/pr-review/pr-review-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { openInPreferredEditor } from "~/editorPreferences";
import { buildPatchCacheKey } from "~/lib/diffRendering";
import { ensureNativeApi } from "~/nativeApi";
import { inferLanguageIdForPath } from "~/vscode-icons";
import { normalizeLanguageIdForHighlighting } from "~/lib/languageIds";

export type PullRequestState = "open" | "closed" | "merged";
export type InspectorTab = "threads" | "workflow" | "people";
Expand Down Expand Up @@ -170,11 +171,11 @@ export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {

export function resolveFileDiffLanguage(fileDiff: FileDiffMetadata): SupportedLanguages | null {
if (fileDiff.lang != null) {
return fileDiff.lang;
return normalizeLanguageIdForHighlighting(fileDiff.lang) as SupportedLanguages;
}
const path = resolveFileDiffPath(fileDiff);
const languageId = inferLanguageIdForPath(path);
return languageId ? (languageId as SupportedLanguages) : null;
return languageId ? (normalizeLanguageIdForHighlighting(languageId) as SupportedLanguages) : null;
}

export function withInferredFileDiffLanguage(fileDiff: FileDiffMetadata): FileDiffMetadata {
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/lib/languageIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { bundledLanguages, bundledLanguagesAlias } from "shiki";

const LANGUAGE_ID_OVERRIDES: Record<string, string> = {
"bun.lockb": "text",
"code-text-binary": "text",
cfmhtml: "html",
csharp: "c#",
"django-html": "html",
esphome: "yaml",
"go.mod": "go",
"go.work": "go",
"json-tmlanguage": "json",
javascriptreact: "jsx",
"manifest-yaml": "yaml",
plaintext: "text",
proto3: "protobuf",
rmd: "md",
restructuredtext: "rst",
swagger: "yaml",
typescriptreact: "tsx",
"yaml-tmlanguage": "yaml",
};

export function normalizeLanguageIdForHighlighting(languageId: string): string {
const normalized = languageId.toLowerCase();
if (
Object.hasOwn(bundledLanguages, normalized) ||
Object.hasOwn(bundledLanguagesAlias, normalized)
) {
return normalized;
}
return LANGUAGE_ID_OVERRIDES[normalized] ?? normalized;
}
19 changes: 3 additions & 16 deletions apps/web/src/lib/syntaxHighlighting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,10 @@ import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pie

import { LRUCache } from "./lruCache";
import { fnv1a32, resolveDiffThemeName, type DiffThemeName } from "./diffRendering";
import { normalizeLanguageIdForHighlighting } from "./languageIds";

const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;

/**
* Map VSCode language identifiers that don't match Shiki's bundled language names.
* VSCode uses e.g. "typescriptreact" / "javascriptreact" while Shiki expects "tsx" / "jsx".
*/
const VSCODE_TO_SHIKI_LANG: Record<string, string> = {
typescriptreact: "tsx",
javascriptreact: "jsx",
};

/** Normalise a language identifier so Shiki can resolve it. */
function normalizeLanguage(language: string): string {
return VSCODE_TO_SHIKI_LANG[language] ?? language;
}

const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
const highlightedCodeCache = new LRUCache<string>(
Expand Down Expand Up @@ -76,7 +63,7 @@ export function setCachedHighlightedHtml(
}

export function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
const normalized = normalizeLanguage(language);
const normalized = normalizeLanguageIdForHighlighting(language);
const cached = highlighterPromiseCache.get(normalized);
if (cached) return cached;

Expand Down Expand Up @@ -104,7 +91,7 @@ export function renderHighlightedCodeHtml(
language: string,
themeName: DiffThemeName,
): string {
const normalized = normalizeLanguage(language);
const normalized = normalizeLanguageIdForHighlighting(language);
try {
return highlighter.codeToHtml(code, { lang: normalized, theme: themeName });
} catch (error) {
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/rightPanelStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { normalizeRightPanelTab } from "./rightPanelStore";
import { afterEach, describe, expect, it } from "vitest";
import { useRightPanelStore, normalizeRightPanelTab } from "./rightPanelStore";

describe("normalizeRightPanelTab", () => {
it("maps legacy files and editor tabs into the workspace tab", () => {
Expand All @@ -14,3 +14,24 @@ describe("normalizeRightPanelTab", () => {
expect(normalizeRightPanelTab(null)).toBeNull();
});
});

describe("useRightPanelStore setActiveTab", () => {
afterEach(() => {
useRightPanelStore.setState({
isOpen: false,
activeTab: "workspace",
});
});

it("can retarget the active tab without opening the panel", () => {
useRightPanelStore.setState({
isOpen: false,
activeTab: "diffs",
});

useRightPanelStore.getState().setActiveTab("workspace", false);

expect(useRightPanelStore.getState().activeTab).toBe("workspace");
expect(useRightPanelStore.getState().isOpen).toBe(false);
});
});
6 changes: 3 additions & 3 deletions apps/web/src/rightPanelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface RightPanelState {
activeTab: RightPanelTab;
open: (tab?: RightPanelTab) => void;
close: () => void;
setActiveTab: (tab: RightPanelTab) => void;
setActiveTab: (tab: RightPanelTab, open?: boolean) => void;
}

const STORAGE_KEY = "okcode:right-panel-tab:v1";
Expand Down Expand Up @@ -62,8 +62,8 @@ export const useRightPanelStore = create<RightPanelState>((set) => ({

close: () => set({ isOpen: false }),

setActiveTab: (tab) => {
setActiveTab: (tab, open = true) => {
persistTab(tab);
set({ activeTab: tab, isOpen: true });
set((state) => ({ activeTab: tab, isOpen: open ? true : state.isOpen }));
},
}));
15 changes: 14 additions & 1 deletion apps/web/src/routes/_chat.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function ChatThreadRouteView() {
const rightPanelTab = useRightPanelStore((s) => s.activeTab);
const openRightPanel = useRightPanelStore((s) => s.open);
const closeRightPanel = useRightPanelStore((s) => s.close);
const setRightPanelTab = useRightPanelStore((s) => s.setActiveTab);

// ── Code viewer state ─────────────────────────────────────────────
const codeViewerOpen = useCodeViewerStore((state) => state.isOpen);
Expand All @@ -223,6 +224,12 @@ function ChatThreadRouteView() {
if (!project) return null;
return thread?.worktreePath ?? draftThread?.worktreePath ?? project.cwd;
});
const hasThreadDiffs = useStore(
(store) =>
store.threads
.find((t) => t.id === threadId)
?.turnDiffSummaries.some((summary) => summary.files.length > 0) ?? false,
);

// ── Keep-alive flags so lazy content doesn't unmount on tab switch ─
const [hasOpenedSimulation, setHasOpenedSimulation] = useState(simulationOpen);
Expand Down Expand Up @@ -295,6 +302,12 @@ function ChatThreadRouteView() {
}
}, [diffViewerOpen, openRightPanel]);

useEffect(() => {
if (!hasThreadDiffs && rightPanelTab === "diffs") {
setRightPanelTab("workspace", false);
}
}, [hasThreadDiffs, rightPanelTab, setRightPanelTab]);

// ── Sync right panel close → close sub-panels ─────────────────────
const prevRightPanelOpenRef = useRef(rightPanelOpen);
useEffect(() => {
Expand Down Expand Up @@ -335,7 +348,7 @@ function ChatThreadRouteView() {
// ── Right panel content (shared between desktop sidebar & mobile sheet) ──
const rightPanelContent = (
<div className="flex min-h-0 flex-1 flex-col bg-background">
<RightPanelHeader />
<RightPanelHeader hasDiffs={hasThreadDiffs} />
<div className="relative flex-1 overflow-hidden">
{rightPanelTab === "workspace" ? (
<WorkspacePanel
Expand Down
Loading