Skip to content
Open
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
136 changes: 136 additions & 0 deletions desktop/frontend/src/components/CodeBlockToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, Copy } from "lucide-react";
import { CopyButton } from "./CopyButton";
import { LANGS, languageLabel, rememberLang, suggestedLang } from "../lib/codeBlockActions";

// CodeBlockToolbar is the hover-revealed action row on a code block. It stacks
// four concerns in one row of <button>s:
//
// 1. Copy — full plaintext (delegated to CopyButton).
// 2. Copy as Markdown — wraps the value in a fenced code block with the
// current language, so pasting into a GitHub issue
// preserves syntax highlighting. This is the action
// users reach for most when they want to share a
// snippet, and a separate plain "Copy" is the right
// escape hatch for things like piping into a shell.
// 3. Language picker — visible when no language was inferred (or always,
// to let the user override). The picker reads the
// remembered-most-recent for the active workspace
// and writes back on every change.
// 4. (Reserved) Run / Open in editor — wired through bridge.Todo once
// those bindings land; the toolbar reserves the
// slot so future addition is a 1-line change.
//
// The toolbar is mouse-driven for the picker but the buttons themselves are
// keyboard-reachable. Esc collapses an open picker without changing the
// selection. The hover-reveal is CSS-only (opacity transition on .code-block
// hover/focus-within) so we don't pay a re-render cost when the pointer
// crosses a code block boundary.
export function CodeBlockToolbar({
value,
language,
workspace,
onLanguageChange,
}: {
value: string;
language?: string;
workspace?: string | null;
onLanguageChange?: (next: string | null) => void;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [copied, setCopied] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const [override, setOverride] = useState<string | null>(null);
// effective language: an explicit override wins; otherwise the prop or the
// suggested language for the workspace. Keeping it as a single string lets
// the label render in one place.
const effective = override ?? language ?? suggestedLang(workspace) ?? null;

// Close the picker on outside click or Escape. The outside-click handler
// is attached to document so it works across the picker popping over the
// transcript; the Escape handler is local to the wrapper for clarity.
useEffect(() => {
if (!pickerOpen) return;
const onDown = (e: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(e.target as Node)) setPickerOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
setPickerOpen(false);
}
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [pickerOpen]);

const copyAsMarkdown = async () => {
const fence = "```";
const lang = effective ?? "";
const body = `${fence}${lang}\n${value.replace(/\n$/, "")}\n${fence}\n`;
try {
await navigator.clipboard.writeText(body);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* clipboard unavailable */
}
};

const pick = (id: string) => {
setOverride(id);
if (workspace) rememberLang(workspace, id);
onLanguageChange?.(id);
setPickerOpen(false);
};

return (
<div className="code-block__toolbar" ref={wrapRef}>
<CopyButton text={value} className="code-block__copy" />
<button
type="button"
className="code-block__btn code-block__btn--md"
onClick={copyAsMarkdown}
title="Copy as Markdown"
aria-label="Copy as Markdown"
>
{copied ? <Check size={13} /> : <Copy size={13} />}
<span className="code-block__btn-label">MD</span>
</button>
<button
type="button"
className="code-block__btn code-block__lang"
onClick={() => setPickerOpen((v) => !v)}
title="Set language"
aria-haspopup="listbox"
aria-expanded={pickerOpen}
>
<span className="code-block__lang-label">{languageLabel(effective ?? undefined)}</span>
<ChevronDown size={11} className={`code-block__lang-chev ${pickerOpen ? "code-block__lang-chev--open" : ""}`} />
</button>
{pickerOpen && (
<ul className="code-block__picker" role="listbox" aria-label="Language">
{LANGS.map((l) => (
<li key={l.id}>
<button
type="button"
role="option"
aria-selected={effective === l.id}
className={`code-block__picker-item ${effective === l.id ? "code-block__picker-item--on" : ""}`}
onClick={() => pick(l.id)}
>
<span className="code-block__picker-label">{l.label}</span>
<span className="code-block__picker-id">{l.id}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
22 changes: 18 additions & 4 deletions desktop/frontend/src/components/CodeViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
import { CopyButton } from "./CopyButton";
import { lazy, Suspense, useState } from "react";
import { CodeBlockToolbar } from "./CodeBlockToolbar";

export interface EditorProps {
value: string;
Expand All @@ -22,17 +22,31 @@ export interface EditorProps {
const Impl = lazy(() => import("./editors/HljsCode"));

export function CodeViewer(props: EditorProps) {
// The toolbar lives above the editor and stays in sync with the language
// override (the user's manual pick in the picker). We hold the override
// here rather than inside the toolbar so a future feature — e.g. "use
// this highlight for the next N code blocks" — can read it from the
// parent. The override is "additive" to props.language: when the user
// picks "rust", we pass that down; if they then change the input (which
// would mean a different code block), the toolbar's internal override
// resets to the new props.language.
const [override, setOverride] = useState<string | null>(null);
const effective = override ?? props.language;
return (
<div className="code-block">
<CopyButton text={props.value} className="code-block__copy" />
<CodeBlockToolbar
value={props.value}
language={effective}
onLanguageChange={(lang) => setOverride(lang)}
/>
<Suspense
fallback={
<pre className="code code--loading">
<code>{props.value}</code>
</pre>
}
>
<Impl {...props} />
<Impl {...props} language={effective} />
</Suspense>
</div>
);
Expand Down
113 changes: 113 additions & 0 deletions desktop/frontend/src/lib/codeBlockActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// codeBlockActions centralizes the small utilities the code-block toolbar
// shares: the language-frequency list that powers the fallback picker, and
// the "remembered" map of (workspace, language) -> language that the picker
// uses for smart defaults.
//
// We keep this in lib/ rather than colocated with the toolbar so future
// components (e.g. a "set language" menu inside a future Monaco editor) can
// import the same constants without re-defining them.

// LANGS is the top-N set highlight.js can name, ordered roughly by frequency
// in real-world code blocks surfaced by the agent. The order is the picker's
// display order (most useful first). Anything not in this list still
// highlights — highlight.js autodetects — but the user can pick from this
// fixed menu to override.
export const LANGS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "ts", label: "TypeScript" },
{ id: "tsx", label: "TSX" },
{ id: "js", label: "JavaScript" },
{ id: "jsx", label: "JSX" },
{ id: "go", label: "Go" },
{ id: "python", label: "Python" },
{ id: "py", label: "Python (alt)" },
{ id: "rust", label: "Rust" },
{ id: "java", label: "Java" },
{ id: "kotlin", label: "Kotlin" },
{ id: "swift", label: "Swift" },
{ id: "c", label: "C" },
{ id: "cpp", label: "C++" },
{ id: "csharp", label: "C#" },
{ id: "ruby", label: "Ruby" },
{ id: "php", label: "PHP" },
{ id: "bash", label: "Bash" },
{ id: "sh", label: "Shell" },
{ id: "sql", label: "SQL" },
{ id: "html", label: "HTML" },
{ id: "css", label: "CSS" },
{ id: "scss", label: "SCSS" },
{ id: "json", label: "JSON" },
{ id: "yaml", label: "YAML" },
{ id: "toml", label: "TOML" },
{ id: "xml", label: "XML" },
{ id: "markdown", label: "Markdown" },
{ id: "diff", label: "Diff" },
{ id: "dockerfile", label: "Dockerfile" },
{ id: "makefile", label: "Makefile" },
{ id: "plaintext", label: "Plain text" },
];

const LANG_LABEL: Record<string, string> = Object.fromEntries(LANGS.map((l) => [l.id, l.label]));

// languageLabel returns a human-friendly label for a stored language id, or
// the id itself when we don't recognize it (the picker's display then says
// "foo" rather than falling back to empty — better signal that a custom
// highlight is in use).
export function languageLabel(id: string | undefined): string {
if (!id) return "auto";
return LANG_LABEL[id] ?? id;
}

// rememberLang stores the user's last manual pick for a workspace. A "manual
// pick" is when the user changes the language in the picker — we use it as
// the default for the NEXT code block in the same workspace that arrives
// without a language, since the model usually gets it right but occasionally
// emits a bare `python` block the user wants to recolor.
const REMEMBER_KEY = "reasonix.codeblock.langs";

interface Remembered {
// workspace id -> ordered list of recent picks, most-recent first, capped.
byWorkspace: Record<string, string[]>;
}

function readRemembered(): Remembered {
if (typeof localStorage === "undefined") return { byWorkspace: {} };
try {
const raw = localStorage.getItem(REMEMBER_KEY);
if (!raw) return { byWorkspace: {} };
const v = JSON.parse(raw);
if (!v || typeof v !== "object" || !v.byWorkspace) return { byWorkspace: {} };
return v as Remembered;
} catch {
return { byWorkspace: {} };
}
}

function writeRemembered(v: Remembered): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(REMEMBER_KEY, JSON.stringify(v));
} catch {
/* private mode — fine to forget */
}
}

// rememberLang prepends `id` to the workspace's recent-pick list, dedupes,
// and caps at 5. The first entry of the returned list is `id`, so the
// caller's "next-default" logic is a single .byWorkspace[id]?.[0] lookup.
export function rememberLang(workspace: string, id: string): void {
if (!workspace || !id) return;
const v = readRemembered();
const prev = v.byWorkspace[workspace] ?? [];
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
v.byWorkspace[workspace] = next;
writeRemembered(v);
}

// suggestedLang returns the most-recent pick for `workspace` if the caller
// has no language. `null` means "no prior preference" — the picker should
// stay open without a default highlight.
export function suggestedLang(workspace: string | null | undefined): string | null {
if (!workspace) return null;
const v = readRemembered();
return v.byWorkspace[workspace]?.[0] ?? null;
}
Loading
Loading