diff --git a/.github/scripts/comment-pr-artifacts.cjs b/.github/scripts/comment-pr-artifacts.cjs new file mode 100644 index 000000000..c463a4989 --- /dev/null +++ b/.github/scripts/comment-pr-artifacts.cjs @@ -0,0 +1,33 @@ +const marker = '' + +async function commentPRArtifacts({ github, context, core, allowedActors = '' }) { + const { owner, repo } = context.repo + const run = context.payload.workflow_run + if (!run || run.event !== 'pull_request' || run.status !== 'completed' || run.conclusion !== 'success') return + if (run.path !== '.github/workflows/pr-build.yml') return + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { owner, repo, run_id: run.id, per_page: 100 }) + const active = artifacts.filter(artifact => !artifact.expired) + if (!active.length) { core.info('No active artifacts to announce.'); return } + const associated = run.pull_requests?.length ? run.pull_requests : await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, { owner, repo, commit_sha: run.head_sha, per_page: 100 }) + const allowed = new Set(allowedActors.split(',').map(actor => actor.trim()).filter(Boolean)) + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${run.id}` + // Artifact names are untrusted display text, never code or a URL to execute. + const displayName = name => String(name).replace(/[\r\n]/g, ' ').replace(/[\\`*_[\]<>]/g, '\\$&') + const body = [marker, 'PR builds are available as GitHub Actions artifacts:', '', runUrl, '', + 'Artifacts expire according to the retention shown on the run.', 'Artifacts:', + ...active.map(artifact => `- ${displayName(artifact.name)}`)].join('\n') + for (const number of new Set(associated.map(pr => pr.number))) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number }) + // A completed old run must not advertise obsolete artifacts after a push. + if (pr.head.sha !== run.head_sha || pr.draft) continue + if (!['dev', 'DEV-v2'].includes(pr.base.ref) && !allowed.has(pr.user.login)) continue + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100 }) + const existing = comments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.startsWith(marker)) + if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }) + else await github.rest.issues.createComment({ owner, repo, issue_number: number, body }) + core.info(`Announced artifacts for completed run ${run.id} on PR #${number}.`) + } +} + +module.exports = { commentPRArtifacts } diff --git a/.github/scripts/comment-pr-artifacts.test.cjs b/.github/scripts/comment-pr-artifacts.test.cjs new file mode 100644 index 000000000..55191221b --- /dev/null +++ b/.github/scripts/comment-pr-artifacts.test.cjs @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') +const { commentPRArtifacts } = require('./comment-pr-artifacts.cjs') + +function fixture() { + const run = { id: 123, head_sha: 'reviewed', event: 'pull_request', status: 'completed', conclusion: 'success', + path: '.github/workflows/pr-build.yml', pull_requests: [{ number: 825 }] } + const pr = { head: { sha: 'reviewed' }, base: { ref: 'dev' }, user: { login: 'contributor' }, draft: false } + const calls = [], writes = [], artifacts = [{ name: 'build-windows', expired: false }], comments = [] + const methods = { artifacts: {}, associated: {}, comments: {} } + const github = { + rest: { actions: { listWorkflowRunArtifacts: methods.artifacts }, repos: { listPullRequestsAssociatedWithCommit: methods.associated }, + pulls: { get: async () => ({ data: pr }) }, issues: { + listComments: methods.comments, createComment: async input => writes.push(input), updateComment: async input => writes.push(input), + } }, + paginate: async (method, input) => { + calls.push(input) + if (method === methods.artifacts) return artifacts + if (method === methods.associated) return [{ number: 825 }] + if (method === methods.comments) return comments + throw new Error('Unexpected API') + }, + } + const execute = (allowedActors = '') => commentPRArtifacts({ github, context: { repo: { owner: 'owner', repo: 'repo' }, payload: { workflow_run: run } }, + core: { info() {} }, allowedActors }) + return { run, pr, calls, writes, artifacts, comments, execute, github } +} + +test('completed run posts artifacts without searching or waiting for another workflow', async () => { + const f = fixture(); await f.execute() + assert.equal(f.writes.length, 1) + assert.equal(f.writes[0].issue_number, 825) + assert.match(f.writes[0].body, /actions\/runs\/123/) + assert.equal(f.calls[0].run_id, 123) +}) +test('failed, canceled, incomplete and unrelated runs perform no artifact or comment calls', async () => { + for (const patch of [{ conclusion: 'failure' }, { conclusion: 'cancelled' }, { status: 'in_progress' }, + { event: 'push' }, { path: '.github/workflows/other.yml' }]) { + const f = fixture(); Object.assign(f.run, patch); await f.execute() + assert.deepEqual(f.calls, []); assert.deepEqual(f.writes, []) + } +}) +test('missing PR association uses GitHub commit association and still verifies the current head', async () => { + const f = fixture(); f.run.pull_requests = []; await f.execute() + assert(f.calls.some(call => call.commit_sha === 'reviewed')) + assert.equal(f.writes.length, 1) +}) +test('obsolete heads, drafts and unauthorized targets do not get artifact announcements', async () => { + for (const change of [pr => { pr.head.sha = 'new-head' }, pr => { pr.draft = true }, pr => { pr.base.ref = 'main' }]) { + const f = fixture(); change(f.pr); await f.execute(); assert.deepEqual(f.writes, []) + } +}) +test('expired or absent artifacts are skipped rather than falsely advertised', async () => { + const f = fixture(); f.artifacts[0].expired = true; await f.execute(); assert.deepEqual(f.writes, []) +}) +test('explicitly allowed authors retain access for non-dev PR targets', async () => { + const f = fixture(); f.pr.base.ref = 'main'; await f.execute('maintainer, contributor') + assert.equal(f.writes.length, 1) +}) +test('a rerun updates only the bot-owned artifact comment', async () => { + const f = fixture() + f.comments.push({ id: 1, user: { login: 'contributor' }, body: '' }, + { id: 2, user: { login: 'github-actions[bot]' }, body: '\nold' }) + await f.execute(); assert.equal(f.writes[0].comment_id, 2) +}) +test('artifact names are escaped display text, not executable markdown content', async () => { + const f = fixture(); f.artifacts[0].name = '[bad](url)\n')) + }) + } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null }, +}) +let browser +try { + await server.listen() + const origin = `http://127.0.0.1:${server.httpServer.address().port}` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) + const page = await browser.newPage({ viewport: { width: 1600, height: 900 }, deviceScaleFactor: 1, colorScheme: "dark", timezoneId: "UTC" }) + const errors = [] + page.on("pageerror", error => { errors.push(error.message); console.error(error.message) }) + page.on("console", message => { if (message.type() === "error") console.error(message.text()) }) + // No user backend, shared daemon, provider, or external website is contacted. + await page.route("**/*", async route => { + const url = new URL(route.request().url()) + if (url.origin !== origin) return route.abort() + if (url.pathname === "/api/events") return route.continue() + if (url.pathname.includes("/api/") || url.pathname.startsWith("/workspaces/")) { + return route.fulfill({ contentType: "application/json", body: "{}" }) + } + return route.continue() + }) + await page.goto(`${origin}/readme`) + await page.getByText("Keyboard navigation is ready", { exact: true }).waitFor() + await page.getByText("Connected", { exact: true }).waitFor() + await page.getByText("project-docs", { exact: true }).waitFor() + await page.getByRole("button", { name: "Agent: Build", exact: true }).waitFor() + assert.equal(await page.locator("[data-app-tab-id]").count(), 8, "Show all eight demo projects") + await page.getByText("Add workspace notifications", { exact: true }).waitFor() + await page.waitForFunction(() => Number(document.querySelector(".message-timeline")?.getAttribute("data-segment-count")) >= 180) + await page.locator('.message-timeline-segment[data-message-id="msg_01"]').waitFor() + await page.evaluate(() => document.fonts.ready) + await page.waitForTimeout(1500) + assert.equal(await page.locator(".keyboard-hints:visible, .kbd-hint:visible").count(), 0, + "Keyboard shortcut hints must remain hidden with the default preference") + assert.deepEqual(errors, [], "The documentation fixture must render without uncaught errors") + await mkdir(path.dirname(output), { recursive: true }) + await page.screenshot({ path: output, animations: "disabled" }) + console.log(output) +} finally { + await browser?.close() + await server.close() +} diff --git a/packages/ui/scripts/monaco-public-assets.js b/packages/ui/scripts/monaco-public-assets.js index 6ee3eaf73..e5e72bbfb 100644 --- a/packages/ui/scripts/monaco-public-assets.js +++ b/packages/ui/scripts/monaco-public-assets.js @@ -77,13 +77,9 @@ export function copyMonacoPublicAssets(params) { } } - // Copy baseline basic tokenizers. - for (const lang of ["python", "markdown", "cpp", "kotlin"]) { - const src = resolve(sourceRoot, "basic-languages", lang) - if (fs.existsSync(src)) { - copyRecursive(src, resolve(destRoot, "basic-languages", lang)) - } - } + // Workspace previews may open any supported language, including YAML and + // TypeScript. Keep tokenizers local so source views work without a CDN. + copyRecursive(resolve(sourceRoot, "basic-languages"), resolve(destRoot, "basic-languages")) // Copy monaco.contribution.js entrypoints (needed by some loads). const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js") diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx index cb905efef..86fab340d 100644 --- a/packages/ui/src/components/browser-frame.tsx +++ b/packages/ui/src/components/browser-frame.tsx @@ -1,8 +1,12 @@ import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" import { Show, createEffect, createMemo, createSignal, onCleanup, untrack, type Component } from "solid-js" import { runtimeEnv } from "../lib/runtime-env" +import { useI18n } from "../lib/i18n" +import emulationProfiles from "../lib/native/browser-emulation.json" +import type { BrowserEmulationPreset } from "../lib/native/browser-emulation" import { controlTauriBrowserTarget, + emulateBrowserTarget, nativeBrowserHost, onTauriBrowserNavigation, physicalBrowserBounds, @@ -11,6 +15,7 @@ import { updateTauriBrowserTarget, } from "../lib/native/browser" import { getBrowserFramePolicy, normalizeBrowserPreviewUrl } from "./browser-frame-security" +import WindowCloseButton from "./window-close-button" export interface BrowserFrameElementTarget { pagePath: string @@ -45,8 +50,8 @@ const VIEWPORT_PRESETS: Record void sessionId?: string title: string initialUrl: string @@ -108,6 +114,9 @@ function getElementSelector(element: Element): string { } export const BrowserFrame: Component = (props) => { + const { t } = useI18n() + const [emulationBusy, setEmulationBusy] = createSignal(false) + const [emulationReady, setEmulationReady] = createSignal(false) const [frameSrc, setFrameSrc] = createSignal(props.initialUrl) const [pathInput, setPathInput] = createSignal(props.initialAddress ?? "/") const [viewportPreset, setViewportPreset] = createSignal("responsive") @@ -131,6 +140,8 @@ export const BrowserFrame: Component = (props) => { // Iframe previews also run on HTTP LAN origins without crypto.randomUUID. let browserRegistrationId = nativeBrowserAvailable ? crypto.randomUUID() : "" let disposed = false + let appliedEmulation: { registrationId: string; preset: BrowserEmulationPreset } | undefined + let emulationGeneration = 0 const framePolicy = getBrowserFramePolicy(runtimeEnv) const canComment = createMemo(() => !nativeMode() && (framePolicy.canInspectDom || props.commentBridge) && Boolean(props.onToggleCommentMode && props.onCommentTarget)) @@ -138,6 +149,43 @@ export const BrowserFrame: Component = (props) => { const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) + const emulationProfile = (preset: BrowserViewportPreset): BrowserEmulationPreset => + preset === "mobile" || preset === "mobileLandscape" ? preset : "none" + const applyEmulation = async (registrationId: string, preset: BrowserEmulationPreset) => { + await emulateBrowserTarget(registrationId, preset) + if (!disposed && registrationId === browserRegistrationId) appliedEmulation = { registrationId, preset } + } + const restoreEmulation = async (registrationId: string) => { + // Readiness means we own a registered target, not that a profile succeeded. + // Keep failures retryable, including selecting the same retained template. + setEmulationReady(true) + const profile = untrack(() => emulationProfile(viewportPreset())) + if (profile === "none") return + const generation = ++emulationGeneration + setEmulationBusy(true) + try { await applyEmulation(registrationId, profile) } + catch (error) { + if (!disposed && registrationId === browserRegistrationId) reportNativeError(error) + } finally { + if (!disposed && generation === emulationGeneration) setEmulationBusy(false) + } + } + const selectViewport = async (preset: BrowserViewportPreset) => { + if (emulationBusy()) return + setViewportMenuOpen(false) + const previous = appliedEmulation?.registrationId === browserRegistrationId ? appliedEmulation.preset : undefined + const next = emulationProfile(preset) + if (previous === next || !nativeMode()) { setViewportPreset(preset); return } + setEmulationBusy(true) + const generation = ++emulationGeneration + const registrationId = browserRegistrationId + try { + await applyEmulation(registrationId, next) + if (!disposed && registrationId === browserRegistrationId) setViewportPreset(preset) + } catch (error) { if (registrationId === browserRegistrationId) reportNativeError(error) } + finally { if (!disposed && generation === emulationGeneration) setEmulationBusy(false) } + } + const getEditablePathFromUrl = (url: string): string => { try { const parsed = new URL(url, window.location.origin) @@ -349,6 +397,7 @@ export const BrowserFrame: Component = (props) => { const sessionId = props.sessionId if (!webviewReady || !sessionId || registering) return if (registered && registeredSessionId !== sessionId) { + setEmulationReady(false) const previousId = browserRegistrationId browserRegistrationId = crypto.randomUUID() registering = true @@ -362,6 +411,7 @@ export const BrowserFrame: Component = (props) => { } if (visible === registered) return if (!visible) { + setEmulationReady(false) if (retryTimer) clearTimeout(retryTimer) retryTimer = undefined if (registered) { @@ -384,7 +434,7 @@ export const BrowserFrame: Component = (props) => { sessionId, registrationId, guestWebContentsId: webview.getWebContentsId(), - }).then(() => { + }).then(async () => { registering = false if (!active) { void window.electronAPI?.unregisterBrowserTarget?.(registrationId).catch(reportNativeError) @@ -392,6 +442,7 @@ export const BrowserFrame: Component = (props) => { } registered = true registeredSessionId = sessionId + await restoreEmulation(registrationId) registrationFailures = 0 registrationErrorReported = false syncRegistration() @@ -414,6 +465,7 @@ export const BrowserFrame: Component = (props) => { webview.addEventListener("dom-ready", handleReady) syncRegistration() cleanupWebviewListeners = () => { + setEmulationReady(false) active = false if (retryTimer) clearTimeout(retryTimer) webview.removeEventListener("did-navigate", syncLocation) @@ -487,6 +539,7 @@ export const BrowserFrame: Component = (props) => { nativeVisible = false registeredSessionId = "" tauriRegistered = false + setEmulationReady(false) void unregisterTauriBrowserTarget(previousId).catch(reportNativeError).finally(() => { registering = false if (active) syncBounds() @@ -537,6 +590,7 @@ export const BrowserFrame: Component = (props) => { registered = true nativeVisible = true tauriRegistered = true + await restoreEmulation(registrationId) syncErrorReported = false if (nativeTarget() !== target) { await controlTauriBrowserTarget(registrationId, "navigate", nativeTarget()) @@ -679,7 +733,7 @@ export const BrowserFrame: Component = (props) => { return (
-
+
@@ -709,7 +763,7 @@ export const BrowserFrame: Component = (props) => { -
+
= (props) => { }) function handleKeyDown(e: KeyboardEvent) { + // Header controls and focused result buttons retain native activation. + if (e.target !== inputRef) return const ordered = orderedCommands() if (ordered.length === 0) { @@ -221,6 +224,7 @@ const CommandPalette: Component = (props) => { open={props.open} onClose={props.onClose} title={t("commandPalette.title")} + initialFocus={() => inputRef} description={t("commandPalette.description")} class="fixed top-[20vh] left-1/2 -translate-x-1/2 z-50 w-[calc(100%-2rem)] max-w-2xl max-h-[60vh]" onKeyDown={handleKeyDown} @@ -245,8 +249,9 @@ const CommandPalette: Component = (props) => { setSelectedCommandId(null) }} placeholder={t("commandPalette.searchPlaceholder")} - class="modal-search-input" + class="modal-search-input min-w-0" /> +
diff --git a/packages/ui/src/components/directory-browser-address.tsx b/packages/ui/src/components/directory-browser-address.tsx index b3b9387b4..71750d00e 100644 --- a/packages/ui/src/components/directory-browser-address.tsx +++ b/packages/ui/src/components/directory-browser-address.tsx @@ -1,11 +1,11 @@ import { Component, For, Show, createEffect, createSignal, createUniqueId } from "solid-js" -import { ArrowLeft, ArrowUpLeft, Folder, Home } from "lucide-solid" +import { ArrowLeft, Folder, FolderRoot, Home } from "lucide-solid" import { useI18n } from "../lib/i18n" export interface DirectoryDestination { target: string label: string - kind: "initial" | "parent" | "home" | "folder" + kind: "initial" | "root" | "home" | "folder" } interface DirectoryBrowserAddressProps { @@ -92,7 +92,7 @@ const DirectoryBrowserAddress: Component = (props) onClick={() => { props.onChoose(destination); props.onOpenChange(false); setActive(-1) }} > {destination.kind === "initial" ? - : destination.kind === "parent" ? + : destination.kind === "root" ? : destination.kind === "home" ? : } {destination.label} diff --git a/packages/ui/src/components/directory-browser-dialog.tsx b/packages/ui/src/components/directory-browser-dialog.tsx index d7379c5e7..904276155 100644 --- a/packages/ui/src/components/directory-browser-dialog.tsx +++ b/packages/ui/src/components/directory-browser-dialog.tsx @@ -1,5 +1,5 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" -import { ArrowRightSquare, File as FileIcon, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid" +import { ArrowRightSquare, ArrowUpLeft, File as FileIcon, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" @@ -263,6 +263,7 @@ const DirectoryBrowserDialog: Component = (props) = }) function handleNavigateTo(path: string) { + setAddressOpen(false) setPathInputDirty(false) void navigateTo(path) } @@ -298,13 +299,12 @@ const DirectoryBrowserDialog: Component = (props) = const name = initial.split(/[\\/]/).filter(Boolean).at(-1) ?? initial add({ target: initial, label: t("directoryBrowser.goToInitial", { name }), kind: "initial" }) } + if (meta.rootPath) { + add({ target: meta.rootPath, label: t("directoryBrowser.goToRoot"), kind: "root" }) + } if (meta.scope === "unrestricted" && meta.homePath) { add({ target: meta.homePath, label: t("directoryBrowser.goToHome"), kind: "home" }) } - if (meta.parentPath) { - const parent = meta.pathKind === "relative" ? resolveAbsolutePath(meta.rootPath, meta.parentPath) : meta.parentPath - add({ target: parent, label: t("directoryBrowser.goToParent"), kind: "parent" }) - } // Use the already loaded listing; typing never starts a speculative filesystem request. const input = pathInput().trim().replace(/\\/g, "/") const normalizedCurrent = current.replace(/\\/g, "/") @@ -493,10 +493,27 @@ const DirectoryBrowserDialog: Component = (props) = } > 0} + when={folderRows().length > 0 || Boolean(currentMetadata()?.parentPath)} fallback={
{t("directoryBrowser.noFolders")}
} >
+ + {(parent) => ( +
+
+ {/* Keep address blur from reflowing the list before the click lands. */} + +
+
+ )} +
{(entry) => { const isFolder = entry.type === "directory" diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index 6d4fda3d1..54d96699e 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -3,7 +3,7 @@ import { loadMonaco } from "../../lib/monaco/setup" import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { inferMonacoLanguageId } from "../../lib/monaco/language" import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" -import { useTheme } from "../../lib/theme" +import { useMonacoTheme } from "../../lib/monaco/theme" import { parsePatchToBeforeAfter } from "../../lib/diff-utils" interface MonacoDiffViewerProps { @@ -67,13 +67,13 @@ function getSplitGutterSizing(options: { before: string; after: string }) { } export function MonacoDiffViewer(props: MonacoDiffViewerProps) { - const { isDark } = useTheme() let host: HTMLDivElement | undefined let diffEditor: any = null let monaco: any = null let splitLayoutFrame: number | null = null const [ready, setReady] = createSignal(false) + useMonacoTheme(() => ready() ? monaco : null) const [hoveredLine, setHoveredLine] = createSignal(null) const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null) const [widgetHovered, setWidgetHovered] = createSignal(false) @@ -194,9 +194,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { if (cancelled) return if (!host || !monaco) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") diffEditor = monaco.editor.createDiffEditor(host, { readOnly: true, + // Read-only previews need selection, not cross-model symbol requests. + // Monaco 0.52 leaks their cancellation on rapid model replacement. + occurrencesHighlight: "off", automaticLayout: true, renderSideBySide: true, renderSideBySideInlineBreakpoint: 0, @@ -235,11 +237,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { }) }) - createEffect(() => { - if (!ready() || !monaco || !diffEditor) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") - }) - createEffect(() => { if (!host) return host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified" diff --git a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx index 089d85997..dc1b6b8d7 100644 --- a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx @@ -3,12 +3,13 @@ import { loadMonaco } from "../../lib/monaco/setup" import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { inferMonacoLanguageId } from "../../lib/monaco/language" import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" -import { useTheme } from "../../lib/theme" +import { useMonacoTheme } from "../../lib/monaco/theme" interface MonacoFileViewerProps { scopeKey: string path: string content: string + readOnly?: boolean wordWrap?: "on" | "off" compactGutter?: boolean onSave?: (content: string) => void @@ -16,12 +17,12 @@ interface MonacoFileViewerProps { } export function MonacoFileViewer(props: MonacoFileViewerProps) { - const { isDark } = useTheme() let host: HTMLDivElement | undefined let editor: any = null let monaco: any = null const [ready, setReady] = createSignal(false) + useMonacoTheme(() => ready() ? monaco : null) const disposeEditor = () => { try { @@ -55,11 +56,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { if (cancelled) return if (!host || !monaco) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") editor = monaco.editor.create(host, { value: "", language: "plaintext", - readOnly: false, + readOnly: props.readOnly ?? false, + occurrencesHighlight: props.readOnly ? "off" : "singleFile", automaticLayout: true, lineNumbers: "on", lineNumbersMinChars: lineNumbersMinChars(props.content), @@ -91,11 +92,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { }) }) - createEffect(() => { - if (!ready() || !monaco || !editor) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") - }) - createEffect(() => { if (!ready() || !editor) return editor.updateOptions({ wordWrap: props.wordWrap === "on" ? "on" : "off" }) diff --git a/packages/ui/src/components/files-preview-view.tsx b/packages/ui/src/components/files-preview-view.tsx new file mode 100644 index 000000000..5183af0e9 --- /dev/null +++ b/packages/ui/src/components/files-preview-view.tsx @@ -0,0 +1,10 @@ +import { Show } from "solid-js" +import type { FilePreviewTarget } from "../stores/files-preview" +import { GitDiffView } from "./git-diff-view" +import { WorkspaceFileView } from "./workspace-file-view" + +export function FilesPreviewView(props: { instanceId: string; target: FilePreviewTarget; active: boolean; onClose: () => void; onInsertComment?: (text: string) => void }) { + return }> + + +} diff --git a/packages/ui/src/components/form-request-auto-open.ts b/packages/ui/src/components/form-request-auto-open.ts deleted file mode 100644 index 42043377e..000000000 --- a/packages/ui/src/components/form-request-auto-open.ts +++ /dev/null @@ -1,12 +0,0 @@ -interface ActiveInterruption { - kind: string - id: string -} - -export function getFormRequestAutoOpenId( - active: ActiveInterruption | null | undefined, - lastOpenedId: string | null, -): string | null { - if (active?.kind !== "form" || active.id === lastOpenedId) return null - return active.id -} diff --git a/packages/ui/src/components/form-request-tool-target.test.ts b/packages/ui/src/components/form-request-tool-target.test.ts index 12e7a9ebc..4363e9785 100644 --- a/packages/ui/src/components/form-request-tool-target.test.ts +++ b/packages/ui/src/components/form-request-tool-target.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { shouldRenderFormInFallback } from "./form-request-tool-target.ts" +import { explicitToolReference, resolveFormToolTarget } from "./form-request-tool-target.ts" describe("form request tool target", () => { - it("uses the fallback until an inline tool target exists", () => { + it("retains the source reference outside the resident transcript window", () => { const form = { id: "form-question", sessionID: "other", title: "Questions", fields: [], metadata: { tool: { messageID: "message-1", id: "call-1" } }, @@ -15,9 +15,9 @@ describe("form request tool target", () => { getMessage: () => ({ partIds: ["call-1"], parts: { "call-1": { data: { id: "call-1", type: "tool" } } } }), } - assert.equal(shouldRenderFormInFallback(form, "current", missing), true) - assert.equal(shouldRenderFormInFallback(form, "other", missing), true) - assert.equal(shouldRenderFormInFallback(form, "other", resolved), false) - assert.equal(shouldRenderFormInFallback({ ...form, metadata: undefined }, "other", missing), true) + assert.deepEqual(explicitToolReference(form), { messageId: "message-1", callId: "call-1" }) + assert.equal(resolveFormToolTarget(form, missing), null) + assert.deepEqual(resolveFormToolTarget(form, resolved), { messageId: "message-1", partId: "call-1" }) + assert.equal(resolveFormToolTarget({ ...form, metadata: undefined }, missing), null) }) }) diff --git a/packages/ui/src/components/form-request-tool-target.ts b/packages/ui/src/components/form-request-tool-target.ts index 9114abe30..d22e7f299 100644 --- a/packages/ui/src/components/form-request-tool-target.ts +++ b/packages/ui/src/components/form-request-tool-target.ts @@ -31,7 +31,7 @@ function metadataRecord(value: unknown): Record | undefined { : undefined } -function explicitToolReference(form: FormInfo) { +export function explicitToolReference(form: FormInfo) { const metadata = metadataRecord(form.metadata) const tool = metadataRecord(metadata?.tool) ?? metadataRecord(metadata?.source) const messageId = tool?.messageID ?? tool?.messageId ?? metadata?.messageID ?? metadata?.messageId @@ -83,11 +83,3 @@ export function resolveFormToolTarget(form: FormInfo, store: MessageStoreReader) } return null } - -export function shouldRenderFormInFallback( - form: FormInfo, - activeSessionId: string | null | undefined, - store: MessageStoreReader, -): boolean { - return form.sessionID !== activeSessionId || !resolveFormToolTarget(form, store) -} diff --git a/packages/ui/src/components/form-request.tsx b/packages/ui/src/components/form-request.tsx index 85eba241f..5ccc6a943 100644 --- a/packages/ui/src/components/form-request.tsx +++ b/packages/ui/src/components/form-request.tsx @@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js" import type { FormAnswer, FormField, FormInfo, FormValue } from "@opencode/client" import { useI18n } from "../lib/i18n" import { getFormAnswer, isFormFieldVisible, isHttpFormUrl } from "../lib/form-schema" +import { isWebSearchProviderForm } from "../lib/websearch-form" interface FormRequestProps { form: FormInfo @@ -48,6 +49,8 @@ export function shouldRenderFormOptionsAsSelect(options: readonly unknown[] | un const FormRequest: Component = (props) => { const { t } = useI18n() + const webSearchProvider = () => isWebSearchProviderForm(props.form) + const title = () => webSearchProvider() ? t("toolCall.websearch.provider") : props.form.title const [values, setValues] = createSignal>( Object.fromEntries(props.form.fields.map((field) => [field.key, getFormFieldDefaultValue(field)])), ) @@ -62,6 +65,7 @@ const FormRequest: Component = (props) => { const submit = async (event: SubmitEvent) => { event.preventDefault() + if (submitting()) return const form = event.currentTarget as HTMLFormElement if (!form.reportValidity()) return const invalidCollection = visibleFields().find((field) => { @@ -89,6 +93,7 @@ const FormRequest: Component = (props) => { } const cancel = async () => { + if (submitting()) return setSubmitting(true) setError(null) try { @@ -100,12 +105,25 @@ const FormRequest: Component = (props) => { } return ( -
-

{props.form.title}

+ +
{ + const scroller = event.currentTarget + const control = event.target + // Native focus scrolling can expose only the text baseline of a control. + requestAnimationFrame(() => { + if (!control.isConnected || document.activeElement !== control || scroller.scrollHeight <= scroller.clientHeight) return + const viewport = scroller.getBoundingClientRect() + const bounds = control.getBoundingClientRect() + if (bounds.bottom > viewport.bottom - 8) scroller.scrollTop += bounds.bottom - viewport.bottom + 8 + else if (bounds.top < viewport.top + 8) scroller.scrollTop += bounds.top - viewport.top - 8 + }) + }}> + {title()} +

{title()}

{(field) => { const descriptionId = `form-${props.form.id}-${field.key}-description` - const label = () => field.title || field.key + const label = () => field.title || (webSearchProvider() ? title() : field.key) const stringField = field as Extract const numberField = field as Extract const stringOptions = () => stringField.options ?? [] @@ -285,8 +303,9 @@ const FormRequest: Component = (props) => { ) }} +
{(message) => } -
+ }> + + +
}>{image => }} + +} diff --git a/packages/ui/src/components/git-image-preview.tsx b/packages/ui/src/components/git-image-preview.tsx new file mode 100644 index 000000000..be5416981 --- /dev/null +++ b/packages/ui/src/components/git-image-preview.tsx @@ -0,0 +1,19 @@ +import { For, Show, createEffect, createSignal, on } from "solid-js" +import type { GitImageDiff } from "../../../server/src/git-history-types" +import { useI18n } from "../lib/i18n" + +export function GitImagePreview(props: { image: GitImageDiff; path: string; view: "split" | "unified" }) { + const { t } = useI18n() + return
+ {side => { + const [failed, setFailed] = createSignal(false) + createEffect(on(() => props.image[side], () => setFailed(false))) + return {bytes =>
+
{t(`filesPanel.image.${side}`)}
+ {t("filesPanel.binary")}

}> + {`${props.path} setFailed(true)} /> +
+
}
+ }}
+
+} diff --git a/packages/ui/src/components/history-statistics.tsx b/packages/ui/src/components/history-statistics.tsx deleted file mode 100644 index a611daeba..000000000 --- a/packages/ui/src/components/history-statistics.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Show, createEffect, createSignal, onCleanup } from "solid-js" -import { useI18n } from "../lib/i18n" -import { walkHistory } from "../stores/session-history" -import { getOpenCodeInstanceGeneration } from "../stores/opencode-data" - -export default function HistoryStatistics(props: { instanceId: string; sessionId?: string }) { - const { t } = useI18n() - const [refresh, setRefresh] = createSignal(0) - const [counts, setCounts] = createSignal({ messages: 0, tools: 0, reasoning: 0, skipped: 0 }) - const [pending, setPending] = createSignal(false) - const [error, setError] = createSignal("") - createEffect(() => { - const instanceId = props.instanceId - const sessionID = props.sessionId - getOpenCodeInstanceGeneration(instanceId) - // Explicit snapshot: streaming events do not restart a whole-history scan. - // Scope/connection changes and the refresh action request a new snapshot. - refresh() - const controller = new AbortController() - setCounts({ messages: 0, tools: 0, reasoning: 0, skipped: 0 }) - setPending(true) - setError("") - void walkHistory(instanceId, { sessionID, query: "", purpose: "stats", includeTechnical: true }, page => { - setCounts(previous => ({ messages: previous.messages + page.scanned, tools: previous.tools + page.tools, - reasoning: previous.reasoning + page.reasoning, skipped: previous.skipped + page.skipped })) - }, controller.signal).catch(error => { - if (!controller.signal.aborted) setError(error instanceof Error ? error.message : String(error)) - }).finally(() => { if (!controller.signal.aborted) setPending(false) }) - onCleanup(() => controller.abort()) - }) - return
- {t("history.counts", counts())} · {pending() ? t("history.scanning") : t("history.snapshot")} - - {t("history.skipped", { count: counts().skipped })} - {error()} -
-} diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index ba51aa4dd..6dac44771 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -127,7 +127,8 @@ const InstanceServiceStatus: Component = (props) => const switchDisabled = () => isPending() || !instance().client const statusDotClass = () => { if (isPending()) return "status-dot animate-pulse" - if (server.status === "running") return "status-dot ready animate-pulse" + // A connected server is idle; only pending operations need motion. + if (server.status === "running") return "status-dot ready" if (server.status === "error") return "status-dot error" return "status-dot stopped" } diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 56d5a832e..052e16cfa 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -25,10 +25,8 @@ import InstanceWelcomeView from "../instance-welcome-view" import InfoView from "../info-view" import CommandPalette from "../command-palette" import PermissionNotificationBanner from "../permission-notification-banner" -import PermissionApprovalModal from "../permission-approval-modal" -import { getFormRequestAutoOpenId } from "../form-request-auto-open" -import { shouldRenderFormInFallback } from "../form-request-tool-target" -import { messageStoreBus } from "../../stores/message-v2/bus" +import { InterruptionDock } from "../interruption-dock" +import { focusInterruption } from "../../stores/interruption-navigation" import SessionView from "../session/session-view" import MessageSection from "../message-section" import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" @@ -41,7 +39,7 @@ import { getLogger } from "../../lib/logger" import PromptInput from "../prompt-input" import PromptContextControls from "../prompt-input/PromptContextControls" import { useI18n } from "../../lib/i18n" -import { activeInterruption, getPermissionQueueLength } from "../../stores/instances" +import { getPermissionQueueLength } from "../../stores/instances" import { getFormQueue } from "../../stores/forms" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" @@ -49,7 +47,7 @@ import RightPanel from "./shell/right-panel/RightPanel" import { registerViewMenuPanels } from "../../lib/native/view-menu" import { useDrawerChrome } from "./shell/useDrawerChrome" import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status" -import { Command as CommandIcon, Globe, Maximize2, Search, ShieldAlert } from "lucide-solid" +import { Command as CommandIcon, Globe, ListFilter, Maximize2, Search, ShieldAlert } from "lucide-solid" import type { PromptInputApi } from "../prompt-input/types" import type { Attachment } from "../../types/attachment" import { setAgentModelPreference, useConfig } from "../../stores/preferences" @@ -88,7 +86,7 @@ import { runtimeEnv } from "../../lib/runtime-env" const log = getLogger("session") const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" -const MIN_SESSION_CENTER_WIDTH = 480 +const MIN_SESSION_CENTER_WIDTH = 390 type SessionCenterWidthStep = "narrow" | "medium" | "wide" function getSessionCenterWidthStep(width: number): SessionCenterWidthStep { @@ -134,13 +132,14 @@ const InstanceShell2: Component = (props) => { const [sessionCenterEl, setSessionCenterEl] = createSignal(null) const [sessionCenterWidthStep, setSessionCenterWidthStep] = createSignal("wide") const [headerDensity, setHeaderDensity] = createSignal(0) + const [narrowHeaderActions, setNarrowHeaderActions] = createSignal(false) + const headerActionsCollapsed = () => narrowHeaderActions() || headerDensity() === 4 + const [filtersOpen, setFiltersOpen] = createSignal(false) let sessionToolbarEl: HTMLElement | undefined let headerLeftEl: HTMLElement | undefined let headerRightEl: HTMLElement | undefined - let headerIndicatorsEl: HTMLElement | undefined + const [headerIndicatorsEl, setHeaderIndicatorsEl] = createSignal() - const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) - let lastAutoOpenedFormId: string | null = null const [now, setNow] = createSignal(Date.now()) const [sessionPromptApis, setSessionPromptApis] = createSignal>({}) const pendingFirstPromptText = new Map() @@ -165,21 +164,9 @@ const InstanceShell2: Component = (props) => { instanceId: () => props.instance.id, }) - createEffect(() => { - const active = activeInterruption().get(props.instance.id) - const form = active?.kind === "form" - ? getFormQueue(props.instance.id).find((entry) => entry.id === active.id) - : undefined - if (form && !shouldRenderFormInFallback(form, activeSessionIdForInstance(), messageStoreBus.getOrCreate(props.instance.id))) { - lastAutoOpenedFormId = form.id - return - } - - const formId = getFormRequestAutoOpenId(active, lastAutoOpenedFormId) - if (!formId) return - lastAutoOpenedFormId = formId - setPermissionModalOpen(true) - }) + // Shell ownership keeps partially answered requests alive as session panes change. + const interruptionPanel = const desktopQuery = useMediaQuery("(min-width: 1280px)") @@ -193,7 +180,6 @@ const InstanceShell2: Component = (props) => { const isPhoneLayout = createMemo(() => layoutMode() === "phone") const mobileFullscreen = createMemo(() => props.mobileFullscreenMode) - const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const { setDrawerHost, drawerContainer, drawerHostWidth, measureDrawerHost } = useDrawerHostMeasure() @@ -323,11 +309,15 @@ const InstanceShell2: Component = (props) => { createEffect(() => { if (!props.isActiveInstance || mobileFullscreen() || typeof ResizeObserver === "undefined") return + // Session/status changes can replace this subtree. Rebind the observer to + // the current node instead of continuing to observe a detached indicator. + const indicators = headerIndicatorsEl() + if (!indicators) return let frame = 0 const measure = () => { cancelAnimationFrame(frame) frame = requestAnimationFrame(() => { - if (!sessionToolbarEl || !headerLeftEl || !headerRightEl || !headerIndicatorsEl) return + if (!sessionToolbarEl || !headerLeftEl || !headerRightEl) return const gap = 8 let density = 4 @@ -335,7 +325,7 @@ const InstanceShell2: Component = (props) => { sessionCenterEl()?.setAttribute("data-session-header-density", String(candidate)) const leftRect = headerLeftEl.getBoundingClientRect() const rightRect = headerRightEl.getBoundingClientRect() - const indicatorsRect = headerIndicatorsEl.getBoundingClientRect() + const indicatorsRect = indicators.getBoundingClientRect() const sidesFit = leftRect.right + gap <= rightRect.left const indicatorsFit = indicatorsRect.width === 0 || (indicatorsRect.left >= leftRect.right + gap && indicatorsRect.right <= rightRect.left - gap) @@ -348,7 +338,7 @@ const InstanceShell2: Component = (props) => { }) } const observer = new ResizeObserver(measure) - ;[sessionToolbarEl, headerLeftEl, headerRightEl, headerIndicatorsEl] + ;[sessionToolbarEl, headerLeftEl, headerRightEl, indicators] .forEach((element) => element && observer.observe(element)) measureDrawerHost() measure() @@ -377,9 +367,18 @@ const InstanceShell2: Component = (props) => { const element = sessionCenterEl() if (!element || typeof ResizeObserver === "undefined") return + let focusFrame = 0 const updateWidthStep = (width: number) => { if (width <= 0) return + const moveFocus = width < 420 && sessionToolbarEl?.querySelector(".session-header-expanded-actions")?.contains(document.activeElement) setSessionCenterWidthStep(getSessionCenterWidthStep(width)) + setNarrowHeaderActions(width < 420) + if (moveFocus) { + cancelAnimationFrame(focusFrame) + focusFrame = requestAnimationFrame(() => { + if (headerActionsCollapsed()) sessionToolbarEl?.querySelector(".session-header-actions-menu")?.focus() + }) + } } measureDrawerHost() @@ -391,7 +390,10 @@ const InstanceShell2: Component = (props) => { }) observer.observe(element) - onCleanup(() => observer.disconnect()) + onCleanup(() => { + observer.disconnect() + cancelAnimationFrame(focusFrame) + }) }) const connectionStatus = () => sseManager.getStatus(props.instance.id) @@ -575,11 +577,11 @@ const InstanceShell2: Component = (props) => { } const renderSessionHeaderIndicators = () => ( -
{ headerIndicatorsEl = element }} class="session-header-indicators flex items-center justify-center gap-2"> +
setPermissionModalOpen(true)} + onClick={() => { handleBackToConversation(); focusInterruption(props.instance.id) }} /> {renderYoloModePill()} @@ -626,6 +628,12 @@ const InstanceShell2: Component = (props) => { const searchWindowId = () => sessionSearchWindowId(props.instance.id, activeSessionIdForInstance() ?? "") const paletteWindowId = () => `command-palette-${props.instance.id}` + createEffect(() => { + activeSessionIdForInstance() + props.isActiveInstance + setFiltersOpen(false) + }) + const headerActionMenuItems = (): ActionOverflowMenuItem[] => { const items: ActionOverflowMenuItem[] = [{ key: "commands", @@ -652,6 +660,12 @@ const InstanceShell2: Component = (props) => { :
= (props) => { {t("instanceShell.connection.disconnected")} - -
-
@@ -1196,12 +1212,12 @@ const InstanceShell2: Component = (props) => { /> + {interruptionPanel} = (props) => { > { if (focusConversationSessionId() === sessionId) setFocusConversationSessionId(null) @@ -1298,11 +1314,6 @@ const InstanceShell2: Component = (props) => { onExecute={props.onExecuteCommand} /> - setPermissionModalOpen(false)} - /> ) } diff --git a/packages/ui/src/components/instance/shell/drawer-layout.test.ts b/packages/ui/src/components/instance/shell/drawer-layout.test.ts index c713ae604..8f13809e1 100644 --- a/packages/ui/src/components/instance/shell/drawer-layout.test.ts +++ b/packages/ui/src/components/instance/shell/drawer-layout.test.ts @@ -4,7 +4,7 @@ import test from "node:test" import { clampEmbeddedDrawerWidth, resolveEmbeddedDrawers } from "./drawer-layout.ts" const widths = { - minimumCenterWidth: 480, + minimumCenterWidth: 390, minimumLeftWidth: 220, minimumRightWidth: 200, leftWidth: 320, @@ -20,31 +20,31 @@ test("drawers shrink before progressively overlaying", () => { leftWidth: 320, rightWidth: 400, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 1_000 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 910 }), { left: true, right: true, leftWidth: 320, rightWidth: 200, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 900 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 810 }), { left: true, right: true, leftWidth: 220, rightWidth: 200, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 700 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 610 }), { left: true, right: false, leftWidth: 220, rightWidth: 400, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 699 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 609 }), { left: false, right: false, leftWidth: 320, rightWidth: 400, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 900, leftOpen: false }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 810, leftOpen: false }), { left: false, right: true, leftWidth: 320, diff --git a/packages/ui/src/components/instance/shell/right-panel/FileRowActions.tsx b/packages/ui/src/components/instance/shell/right-panel/FileRowActions.tsx new file mode 100644 index 000000000..d81177528 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/FileRowActions.tsx @@ -0,0 +1,28 @@ +import type { Component } from "solid-js" +import { For, onMount } from "solid-js" +import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../action-overflow-menu" +import { observeRowOverflow } from "./row-compact-actions" + +// File row actions with the message/session convention: inline icon buttons +// while they fit, the shared overflow menu only when they don't. Hidden +// actions stay measurable but inert; an open menu stays mounted. +const FileRowActions: Component<{ items: ActionOverflowMenuItem[]; label: string }> = props => { + let element!: HTMLSpanElement + onMount(() => observeRowOverflow(element)) + // Preview toggles remain directly reachable; only secondary actions overflow. + const toggles = () => props.items.filter(item => typeof item.checked === "boolean") + const secondary = () => props.items.filter(item => typeof item.checked !== "boolean") + const button = (item: ActionOverflowMenuItem) => + return event.stopPropagation()} onPointerDown={event => event.stopPropagation()}> + {button} + + {button} + + + +} + +export default FileRowActions diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 8459d7b77..2d259758b 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -30,6 +30,7 @@ import { type RightPanelTabModule, } from "./registry" import { createCoreRightPanelRuntime } from "./core-runtime" +import { FILES_PANEL_MIGRATION_KEY, mergeFilesPanelCustomization } from "./files-panel-state" import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest" import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" @@ -93,15 +94,20 @@ interface RightPanelProps { } const RightPanel: Component = (props) => { - const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("git-changes")) + const savedTab = readStoredRightPanelTab("files") + const [rightPanelTab, setRightPanelTab] = createSignal(savedTab === "git-changes" ? "files" : savedTab) const defaultStatusSectionIds = CORE_STATUS_SECTION_ITEMS.map((section) => section.id) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(defaultStatusSectionIds) const [rightPanelCustomizationOpen, setRightPanelCustomizationOpen] = createSignal(false) let customizationTriggerRef: HTMLButtonElement | undefined let customizationPopoverRef: HTMLDivElement | undefined - const [rightPanelCustomization, setRightPanelCustomization] = createSignal( - parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), - ) + let savedCustomization = parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)) + if (!readClientLayoutValue(FILES_PANEL_MIGRATION_KEY)) { + savedCustomization = mergeFilesPanelCustomization(savedCustomization) + writeClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, JSON.stringify(savedCustomization)) + writeClientLayoutValue(FILES_PANEL_MIGRATION_KEY, "1") + } + const [rightPanelCustomization, setRightPanelCustomization] = createSignal(savedCustomization) const tabGroupId = `right-panel-${createUniqueId()}` const tabId = (id: string) => `${tabGroupId}-tab-${id}` const tabPanelId = (id: string) => `${tabGroupId}-panel-${id}` diff --git a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx index 6c2017c4a..c8a104d54 100644 --- a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx @@ -1,6 +1,6 @@ import type { Component } from "solid-js" -import { AlignJustify, FoldVertical, Split, UnfoldVertical } from "lucide-solid" +import { Columns, FoldVertical, Rows, UnfoldVertical } from "lucide-solid" import { useI18n } from "../../../../../lib/i18n" import type { DiffContextMode, DiffViewMode } from "../types" @@ -31,7 +31,7 @@ const DiffToolbar: Component = (props) => { data-active={props.viewMode === "split" ? "true" : undefined} title={viewModeTitle()} > - {nextViewMode() === "split" ?
-
- -
- -
-
-
{props.list.panel()}
-
- - - - - - {props.list.overlay()} - - -
-
- ) -} - -export default SplitFilePanel diff --git a/packages/ui/src/components/instance/shell/right-panel/components/WorkspaceTree.tsx b/packages/ui/src/components/instance/shell/right-panel/components/WorkspaceTree.tsx new file mode 100644 index 000000000..33d19b785 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/WorkspaceTree.tsx @@ -0,0 +1,176 @@ +import { For, Show, createMemo } from "solid-js" +import { ChevronDown, ChevronRight, Copy, ExternalLink, Eye, Folder, FolderOpen, FileCode, FileText, Image, File, RefreshCw, TerminalSquare } from "lucide-solid" +import type { useWorkspaceTree } from "../useWorkspaceTree" +import type { ActionOverflowMenuItem } from "../../../../action-overflow-menu" +import FileRowActions from "../FileRowActions" +import { copyToClipboard } from "../../../../../lib/clipboard" +import { showToastNotification } from "../../../../../lib/notifications" +import { canOpenWorkspacePaths, openWorkspacePath } from "../../../../../lib/workspace-open" + +export function WorkspaceTree(props: { + tree: ReturnType + t: (key: string, vars?: Record) => string + directory: string + instanceId: string + worktreeSlug: string + canOpen: boolean + previewPath?: string + onOpen: (path: string) => void +}) { + const icon = (name: string) => /\.(png|jpe?g|gif|webp|svg|ico|bmp|avif)$/i.test(name) ? Image + : /\.(md|mdx|txt|rst)$/i.test(name) ? FileText + : /\.(tsx?|jsx?|json|ya?ml|css|html|py|rs|sh)$/i.test(name) ? FileCode : File + let root!: HTMLDivElement + const tabStop = createMemo(() => props.tree.rows().some(row => row.path === props.tree.state().selected) + ? props.tree.state().selected : props.tree.rows()[0]?.path) + const focusRow = (path: string) => root.querySelector(`[data-path="${CSS.escape(path)}"]`)?.focus() + const activateRow = (row: ReturnType[number]) => { + props.tree.select(row.path) + if (row.type === "directory") props.tree.toggle(row.path) + } + function keyDown(event: KeyboardEvent, row: ReturnType[number]) { + // The row menu trigger is a separate tab stop; its keys must not toggle the row. + if (event.target !== event.currentTarget) return + const rows = props.tree.rows(), index = rows.findIndex(entry => entry.path === row.path) + const rtl = document.documentElement.dir === "rtl" + if (event.key === "ArrowDown") focusRow(rows[Math.min(index + 1, rows.length - 1)]!.path) + else if (event.key === "ArrowUp") focusRow(rows[Math.max(index - 1, 0)]!.path) + else if (event.key === "Home") focusRow(rows[0]!.path) + else if (event.key === "End") focusRow(rows[rows.length - 1]!.path) + else if (event.key === (rtl ? "ArrowLeft" : "ArrowRight") && row.type === "directory") { + if (!props.tree.state().expanded.has(row.path)) props.tree.toggle(row.path) + else if (rows[index + 1]?.parent === row.path) focusRow(rows[index + 1]!.path) + } else if (event.key === (rtl ? "ArrowRight" : "ArrowLeft")) { + if (row.type === "directory" && props.tree.state().expanded.has(row.path)) props.tree.toggle(row.path) + else focusRow(row.parent) + } else if (event.key === "Enter" || event.key === " ") { + activateRow(row) + } else return + event.preventDefault() + } + + const handleCopyPath = async (path: string) => { + const ok = await copyToClipboard(path) + showToastNotification({ + message: ok ? props.t("instanceShell.filesShell.toast.copyPathSuccess") : props.t("instanceShell.filesShell.toast.copyPathError"), + variant: ok ? "success" : "error", + }) + } + + const handleNativeOpen = async (target: "default" | "reveal" | "terminal", path: string) => { + try { + await openWorkspacePath({ target, instanceId: props.instanceId, worktreeSlug: props.worktreeSlug, path }) + } catch (error) { + showToastNotification({ + message: props.t("instanceShell.filesShell.toast.openError", { + message: error instanceof Error ? error.message : String(error), + }), + variant: "error", + }) + } + } + + const rowActions = (row: ReturnType[number]): ActionOverflowMenuItem[] => { + const items: ActionOverflowMenuItem[] = [] + if (row.type === "file") items.push({ + key: "preview", label: `${props.t("filesPanel.viewer")} · ${row.path}`, + icon: , disabled: !props.canOpen, + checked: props.previewPath === row.path, onSelect: () => props.onOpen(row.path), + }) + if (canOpenWorkspacePaths()) { + if (row.type === "directory") { + const isMacApp = isMacDesktop && row.path.toLowerCase().endsWith(".app") + items.push( + { + key: "open-folder", + label: props.t(isMacApp + ? "instanceShell.filesShell.actions.showInFolder" + : "instanceShell.filesShell.actions.openFolder"), + icon: , + onSelect: () => handleNativeOpen(isMacApp ? "reveal" : "default", row.path), + }, + { + key: "open-terminal", + label: props.t("instanceShell.filesShell.actions.openTerminal"), + icon: , + onSelect: () => handleNativeOpen("terminal", row.path), + }, + ) + } else { + if (isWindowsDesktop) { + items.push({ + key: "open-default", + label: props.t({ + open: "instanceShell.filesShell.actions.openDefault", + edit: "instanceShell.filesShell.actions.editDefault", + choose: "instanceShell.filesShell.actions.chooseApplication", + }[windowsDefaultAction(row.path)]), + icon: , + onSelect: () => handleNativeOpen("default", row.path), + }) + } + items.push({ + key: "show-in-folder", + label: props.t("instanceShell.filesShell.actions.showInFolder"), + icon: , + onSelect: () => handleNativeOpen("reveal", row.path), + }) + } + } + items.push({ + key: "copy-path", + label: props.t("instanceShell.filesShell.actions.copyPath"), + icon: , + onSelect: () => handleCopyPath(row.path), + }) + return items + } + + return
+
{props.directory.replace(/\\/g, "/").split("/").pop()}
+
+ {row => { + const isFolder = row.type === "directory", Icon = icon(row.name) + const expanded = () => props.tree.state().expanded.has(row.path) + return
props.tree.select(row.path)} onKeyDown={event => keyDown(event, row)} + onClick={() => activateRow(row)}> + }> + }> + + }>}> + {row.name} + +
+ }}
+
+ {([path, error]) => } +

{props.t("instanceInfo.loading")}

+
+} + +const isWindowsDesktop = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent) +const isMacDesktop = typeof navigator !== "undefined" && /macintosh|mac os x/i.test(navigator.userAgent) +const safeWindowsOpenExtensions = new Set([ + "7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", "docx", + "env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", "java", "jpeg", "jpg", + "json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", "md", "markdown", "mkv", "mov", "mp3", + "mp4", "ogg", "patch", "pdf", "png", "pptx", "r", "rar", "rmd", "rs", "scss", "sql", "svg", "svelte", + "swift", "tar", "toml", "ts", "tsx", "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip", +]) +const windowsEditExtensions = new Set([ + "bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", "vbs", "wsf", "wsh", +]) + +function windowsDefaultAction(path: string): "open" | "edit" | "choose" { + const name = path.split(/[\\/]/).pop() ?? "" + const dot = name.lastIndexOf(".") + const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : "" + if (!isWindowsDesktop || (extension && safeWindowsOpenExtensions.has(extension))) return "open" + if (extension && windowsEditExtensions.has(extension)) return "edit" + return "choose" +} diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx index a5ce72347..783f8b6a6 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -5,7 +5,6 @@ import type { RightPanelModule } from "./registry" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" interface CoreRightPanelRenderers { - renderGitChangesTab: () => JSX.Element renderFilesTab: () => JSX.Element renderStatusTab: () => JSX.Element } @@ -31,12 +30,6 @@ export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers) descriptionKey: "instanceShell.rightPanel.modules.core.description", origin: "first-party", tabs: [ - { - id: "git-changes", - labelKey: "instanceShell.rightPanel.tabs.gitChanges", - order: 10, - render: renderers.renderGitChangesTab, - }, { id: "files", labelKey: "instanceShell.rightPanel.tabs.files", diff --git a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx index bd8ec9654..ef80c0943 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx @@ -1,38 +1,21 @@ -import { createEffect, createMemo, createSignal, lazy, type Accessor } from "solid-js" - +import { createEffect, createMemo, createSignal, lazy, on, onCleanup, type Accessor } from "solid-js" import type { Instance } from "../../../../types/instance" import type { Session } from "../../../../types/session" import type { PromptInputApi } from "../../../prompt-input/types" -import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" +import type { RightPanelTab } from "./types" import type { RightPanelCustomization, RightPanelSectionModule } from "./registry" - -import { - getDefaultWorktreeSlug, - getGitRepoStatus, - getWorktreeSlugForSession, - getWorktrees, -} from "../../../../stores/worktrees" -import { writeClientLayoutValue } from "../../../../stores/client-state" -import { - RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY, - readStoredBool, - readStoredEnum, -} from "../storage" +import { getDefaultWorktreeSlug, getWorktreeSlugForSession, getWorktrees, getGitRepoStatus } from "../../../../stores/worktrees" +import { closeFilePreview, openFilePreview, type FilePreviewTarget } from "../../../../stores/files-preview" +import { showSessionChat } from "../../../../stores/session-previews" import { useGitChanges } from "./useGitChanges" +import { useGitHistory } from "./useGitHistory" import { createCoreRightPanelManifest } from "./core-plugin" -import { createFilesTabRuntime } from "./tabs/files-runtime" -import { createSplitResize } from "./tabs/split-resize" +import { useWorkspaceTree } from "./useWorkspaceTree" +import { FILES_PANEL_MODE_KEY, type FilesPanelMode } from "./files-panel-state" +import { readStoredEnum } from "../storage" +import { writeClientLayoutValue } from "../../../../stores/client-state" -const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) +const LazyFilesPanel = lazy(() => import("./tabs/FilesPanel")) const LazyStatusTab = lazy(() => import("./tabs/StatusTab")) interface CoreRightPanelRuntimeOptions { @@ -55,181 +38,54 @@ interface CoreRightPanelRuntimeOptions { } export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOptions) { - const [diffViewMode, setDiffViewMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", - ) - const [diffContextMode, setDiffContextMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", - ) - const [diffWordWrapMode, setDiffWordWrapMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on", - ) - const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) - const [gitStagedOpen, setGitStagedOpen] = createSignal(true) - const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true) - - const listLayoutKey = createMemo(() => (options.isPhoneLayout() ? "phone" : "nonphone")) - - const gitListOpenStorageKey = createMemo(() => - listLayoutKey() === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, - ) - - const gitSectionStorageKey = (section: "staged" | "unstaged") => { - const phone = listLayoutKey() === "phone" - if (section === "staged") { - return phone ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY - } - return phone ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY - } - - createEffect(() => { - gitListOpenStorageKey() - const gitPersisted = readStoredBool(gitListOpenStorageKey()) - if (gitPersisted !== null) { - setGitChangesListOpen(gitPersisted) - } else { - setGitChangesListOpen(true) - } - - setGitStagedOpen(readStoredBool(gitSectionStorageKey("staged")) ?? true) - setGitUnstagedOpen(readStoredBool(gitSectionStorageKey("unstaged")) ?? true) - }) - - createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())) - createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())) - createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())) - - const gitChangesSplit = createSplitResize({ - storageKey: RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, - defaultWidth: 320, - rightDrawerWidth: options.rightDrawerWidth, - rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, - }) - - const worktreeSlugForViewer = createMemo(() => { + const [mode, setMode] = createSignal(readStoredEnum(FILES_PANEL_MODE_KEY, ["workspace", "changes", "history"] as const) ?? "workspace") + createEffect(() => writeClientLayoutValue(FILES_PANEL_MODE_KEY, mode())) + const [browsedWorktree, setBrowsedWorktree] = createSignal(null) + const sessionWorktree = createMemo(() => { const sessionId = options.activeSessionId() - if (sessionId && sessionId !== "info") { - return getWorktreeSlugForSession(options.instanceId, sessionId) - } - return getDefaultWorktreeSlug(options.instanceId) - }) - - const gitChangesWorktreeSlug = createMemo(() => { - if (getGitRepoStatus(options.instanceId) === false) return null - const slug = worktreeSlugForViewer().trim() - return slug ? slug : null + return sessionId && sessionId !== "info" ? getWorktreeSlugForSession(options.instanceId, sessionId) : getDefaultWorktreeSlug(options.instanceId) }) - - const gitChangesWorktree = createMemo(() => { - const slug = gitChangesWorktreeSlug() - if (!slug) return null - return getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug) ?? null + createEffect(on(() => `${options.activeSessionId()}:${sessionWorktree()}`, () => { + setBrowsedWorktree(null) + closeFilePreview(options.instanceId) + })) + const slug = createMemo(() => browsedWorktree() ?? sessionWorktree()) + const worktrees = createMemo(() => getWorktrees(options.instanceId)) + const worktree = createMemo(() => worktrees().find(entry => entry.slug === slug())) + createEffect(() => { + // A removed browsed worktree must never silently turn into a root-file read. + if (browsedWorktree() && worktrees().length && !worktree()) { + setBrowsedWorktree(null) + closeFilePreview(options.instanceId) + } }) - - const gitChangesBranchLabel = createMemo(() => gitChangesWorktree()?.branch?.trim() || null) - const gitScopeKey = createMemo(() => `${options.instanceId}:git:${worktreeSlugForViewer()}`) + const active = () => options.isActive() && options.rightPanelTab() === "files" + const gitAvailable = () => getGitRepoStatus(options.instanceId) !== false + createEffect(() => { if (!gitAvailable()) setMode("workspace") }) + const directory = () => worktree()?.directory ?? options.instance.folder + const tree = useWorkspaceTree(options.instanceId, directory, () => active() && mode() === "workspace") + const history = useGitHistory(options.instanceId, slug, () => active() && gitAvailable() && mode() === "history") const git = useGitChanges({ - isActive: options.isActive, - t: options.t, - instanceId: options.instanceId, - rightPanelTab: options.rightPanelTab, - worktreeSlug: worktreeSlugForViewer, - isPhoneLayout: options.isPhoneLayout, - promptInputApi: options.promptInputApi, - closeGitList: () => setGitChangesListOpen(false), + ...options, isActive: () => active() && gitAvailable() && mode() === "changes", rightPanelTab: () => "git-changes", + worktreeSlug: slug, closeGitList: () => {}, externalDiff: true, }) - const renderFilesTab = createFilesTabRuntime({ - t: options.t, - instanceId: options.instanceId, - rightPanelTab: options.rightPanelTab, - worktreeSlug: worktreeSlugForViewer, - isPhoneLayout: options.isPhoneLayout, - rightDrawerWidth: options.rightDrawerWidth, - rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, - }) - - const persistGitListOpen = (value: boolean) => { - writeClientLayoutValue(gitListOpenStorageKey(), value ? "true" : "false") - } - - const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => { - writeClientLayoutValue(gitSectionStorageKey(section), value ? "true" : "false") - } - - const toggleGitList = () => { - setGitChangesListOpen((current) => { - const next = !current - persistGitListOpen(next) - return next - }) + const openFile = (file: Pick) => { + const sessionId = options.activeSessionId() + if (!sessionId || sessionId === "info") return + showSessionChat(options.instance.folder) + openFilePreview(options.instanceId, { ...file, sessionId, slug: slug(), directory: directory(), serviceDirectory: worktree()?.serviceDirectory }) } - + onCleanup(() => closeFilePreview(options.instanceId)) return createCoreRightPanelManifest({ - renderGitChangesTab: () => ( - void git.refreshGitStatus()} - onInsertContext={git.insertGitChangeContext} - onStageFile={git.stageGitFile} - onUnstageFile={git.unstageGitFile} - commitMessage={git.gitCommitMessage} - commitSubmitting={git.gitCommitSubmitting} - onCommitMessageInput={git.setGitCommitMessage} - onSubmitCommit={() => void git.submitGitCommit()} - branchLabel={gitChangesBranchLabel} - stagedOpen={gitStagedOpen} - unstagedOpen={gitUnstagedOpen} - onToggleStagedOpen={() => { - const next = !gitStagedOpen() - setGitStagedOpen(next) - persistGitSectionOpen("staged", next) - }} - onToggleUnstagedOpen={() => { - const next = !gitUnstagedOpen() - setGitUnstagedOpen(next) - persistGitSectionOpen("unstaged", next) - }} - listOpen={gitChangesListOpen} - onToggleList={toggleGitList} - splitWidth={gitChangesSplit.splitWidth} - onResizeMouseDown={gitChangesSplit.onResizeMouseDown} - onResizeTouchStart={gitChangesSplit.onResizeTouchStart} - isPhoneLayout={options.isPhoneLayout} - /> - ), - renderFilesTab, - renderStatusTab: () => ( - options.isActive() && options.rightPanelTab() === "status"} - expandedItems={options.expandedItems} - onExpandedItemsChange={options.onExpandedItemsChange} - customization={options.customization} - onCustomizationChange={options.onCustomizationChange} - extraSections={options.extraStatusSections()} - /> - ), + renderFilesTab: () => { + setBrowsedWorktree(value) + closeFilePreview(options.instanceId) + }} onOpenFile={openFile} canOpenFile={Boolean(options.activeSessionId() && options.activeSessionId() !== "info")} />, + renderStatusTab: () => options.isActive() && options.rightPanelTab() === "status"} + expandedItems={options.expandedItems} onExpandedItemsChange={options.onExpandedItemsChange} + customization={options.customization} onCustomizationChange={options.onCustomizationChange} extraSections={options.extraStatusSections()} />, }) } diff --git a/packages/ui/src/components/instance/shell/right-panel/files-panel-state.test.ts b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.test.ts new file mode 100644 index 000000000..ebc478002 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { mergeFilesPanelCustomization } from "./files-panel-state" + +test("merging Git and Files preserves order and keeps either visible entry accessible", () => { + const state = { tabOrder: ["status", "git-changes", "files"], hiddenTabIds: ["files"], statusSectionOrder: ["tokens"], hiddenStatusSectionIds: [] } + assert.deepEqual(mergeFilesPanelCustomization(state), { ...state, tabOrder: ["status", "files"], hiddenTabIds: [] }) + assert.deepEqual(mergeFilesPanelCustomization({ ...state, hiddenTabIds: ["files", "git-changes"] }).hiddenTabIds, ["files"]) + assert.deepEqual(mergeFilesPanelCustomization({ ...state, tabOrder: ["files", "status", "git-changes"] }).tabOrder, ["files", "status"]) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/files-panel-state.ts b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.ts new file mode 100644 index 000000000..3b54a3d36 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.ts @@ -0,0 +1,14 @@ +import type { RightPanelCustomization } from "./registry" + +export const FILES_PANEL_MIGRATION_KEY = "opencode-files-panel-migration-v1" +export const FILES_PANEL_MODE_KEY = "opencode-files-panel-mode-v1" +export type FilesPanelMode = "workspace" | "changes" | "history" + +// Called once per client layout. Either previously visible entry keeps the +// merged Files surface available; retain the earliest position in the tab strip. +export function mergeFilesPanelCustomization(value: RightPanelCustomization): RightPanelCustomization { + const hidden = value.hiddenTabIds.filter(id => id !== "git-changes" && id !== "files") + if (value.hiddenTabIds.includes("files") && value.hiddenTabIds.includes("git-changes")) hidden.push("files") + return { ...value, hiddenTabIds: hidden, + tabOrder: [...new Set(value.tabOrder.map(id => id === "git-changes" ? "files" : id))] } +} diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts index 2820d504a..0fffe4822 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -62,7 +62,6 @@ describe("right panel plugin manifests", () => { it("defines core right panel tabs and status sections as manifests", () => { const render = () => undefined as any const rightPanel = createCoreRightPanelManifest({ - renderGitChangesTab: render, renderFilesTab: render, renderStatusTab: render, }) @@ -77,7 +76,7 @@ describe("right panel plugin manifests", () => { const rightPanelModule = rightPanel.create(host) - assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"]) + assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["files", "status"]) assert.equal(rightPanelModule.tabs?.find((entry) => entry.id === "status")?.alwaysVisible, true) assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [ "tokens", diff --git a/packages/ui/src/components/instance/shell/right-panel/row-compact-actions.ts b/packages/ui/src/components/instance/shell/right-panel/row-compact-actions.ts new file mode 100644 index 000000000..8efe189af --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/row-compact-actions.ts @@ -0,0 +1,96 @@ +import { onCleanup } from "solid-js" + +const px = (value: string) => Number.parseFloat(value) || 0 + +type Measurement = () => (() => void) | undefined +const pendingMeasurements = new Set() +let scheduledFrame = 0 +function scheduleMeasurement(measure: Measurement) { + pendingMeasurements.add(measure) + if (scheduledFrame) return + scheduledFrame = requestAnimationFrame(() => { + scheduledFrame = 0 + const pending = [...pendingMeasurements] + pendingMeasurements.clear() + // Read every row before changing any DOM. Per-row read/write callbacks + // repeatedly laid out the entire inventory when thousands of files mounted. + const updates = pending.map(read => read()) + for (const update of updates) update?.() + }) +} + +// Width of the row content ignoring the overflow trigger, so hidden inline +// actions stay measurable. Text nodes are measured with a Range because the +// label truncates with ellipsis. +function rowContentWidth(element: Element): number { + if (element.matches(".action-overflow-trigger")) return 0 + const style = getComputedStyle(element) + if (style.display === "none") return 0 + if (element instanceof SVGElement || element.matches("button:not(.git-panel-file-main), .workspace-tree-spacer")) { + return element.getBoundingClientRect().width + } + const widths: number[] = [] + for (const child of element.childNodes) { + if (child instanceof Element) widths.push(rowContentWidth(child)) + else if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) { + const range = document.createRange() + range.selectNode(child) + widths.push(range.getBoundingClientRect().width) + } + } + const visible = widths.filter((width) => width > 0) + const stacked = style.flexDirection === "column" + return (stacked ? Math.max(0, ...visible) : visible.reduce((a, b) => a + b, 0)) + + (stacked ? 0 : Math.max(0, visible.length - 1) * px(style.columnGap)) + + px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth) +} + +// Toggle data-compact on a file row: inline icon buttons while they fit, the +// shared overflow menu only when they don't. An open menu stays mounted +// across resizes; hidden actions stay measurable but inert. +export function observeRowOverflow(element: HTMLElement) { + const row = element.parentElement + if (!row) return + let disposed = false + const measure = () => { + if (disposed || !element.isConnected || !row.getBoundingClientRect().width) return + const menu = element.querySelector(".action-overflow-trigger") + const inline = element.querySelector(".file-row-inline-actions") + if (!menu || !inline) return + // Budget against the entire row, not the shrink-wrapped action group. + // The menu replaces inline actions; it must not add to their width budget. + const required = rowContentWidth(row) + const available = row.getBoundingClientRect().width + const next = menu.hasAttribute("data-expanded") || required > available + 0.5 + if (element.dataset.compact === String(next)) return + const active = document.activeElement + const hadActionFocus = active instanceof Element && element.contains(active) + && active.matches(".file-row-inline-actions button, .action-overflow-trigger") + return () => { + if (disposed || !element.isConnected) return + element.dataset.compact = String(next) + inline.inert = next + if (hadActionFocus) queueMicrotask(() => { + if (disposed || !element.isConnected) return + const target = next ? menu : element.querySelector(".file-row-inline-actions button:not(:disabled)") + target?.focus({ preventScroll: true }) + }) + } + } + const schedule = () => { if (!disposed) scheduleMeasurement(measure) } + const resize = new ResizeObserver(schedule) + resize.observe(row) + resize.observe(element) + const mutation = new MutationObserver(schedule) + mutation.observe(row, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["data-expanded"] }) + document.fonts?.addEventListener("loadingdone", schedule) + schedule() + onCleanup(() => { + disposed = true + pendingMeasurements.delete(measure) + if (!pendingMeasurements.size) { cancelAnimationFrame(scheduledFrame); scheduledFrame = 0 } + resize.disconnect() + mutation.disconnect() + document.fonts?.removeEventListener("loadingdone", schedule) + }) +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesPanel.tsx new file mode 100644 index 000000000..51baf8cbd --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesPanel.tsx @@ -0,0 +1,240 @@ +import { For, Show, createMemo, createSignal, createUniqueId, type Component, type JSX } from "solid-js" +import { + DragDropProvider, + DragDropSensors, + closestCenter, + createDraggable, + createDroppable, + transformStyle, + type DragEvent as SolidDndDragEvent, +} from "@thisbeyond/solid-dnd" +import { ArrowLeft, ChevronRight, Eye, GitBranch, GitCommitHorizontal, Minus, Plus, RefreshCw } from "lucide-solid" +import type { useGitChanges } from "../useGitChanges" +import type { useGitHistory } from "../useGitHistory" +import { closeFilePreview, getFilePreview, type FilePreviewTarget } from "../../../../../stores/files-preview" +import FileRowActions from "../FileRowActions" +import type { ActionOverflowMenuItem } from "../../../../action-overflow-menu" +import { buildGitChangeListItems } from "../git-changes-model" +import type { GitChangeListItem, GitChangeSection } from "../types" +import type { useWorkspaceTree } from "../useWorkspaceTree" +import { WorkspaceTree } from "../components/WorkspaceTree" +import type { FilesPanelMode } from "../files-panel-state" + +interface FilesPanelProps { + t: (key: string, vars?: Record) => string + git: ReturnType + history: ReturnType + tree: ReturnType + gitAvailable: boolean + mode: FilesPanelMode + onModeChange: (mode: FilesPanelMode) => void + worktrees: Array<{ slug: string; directory: string; branch?: string | null }> + slug: string + directory: string + instanceId: string + branch: string | null + onWorktreeChange: (slug: string) => void + onOpenFile: (file: Pick) => void + canOpenFile: boolean +} + +const FilesPanel: Component = props => { + const items = createMemo(() => buildGitChangeListItems(props.git.gitStatusEntries())) + const [filter, setFilter] = createSignal("") + const [selectedCommitFile, setSelectedCommitFile] = createSignal("") + const previewActive = (file: Parameters[0]) => { + const target = getFilePreview(props.instanceId) + return Boolean(target && target.slug === props.slug && target.directory === props.directory && target.path === file.path + && (target.kind ?? "diff") === (file.kind ?? "diff") && target.commit === file.commit && target.scope === file.scope) + } + const togglePreview: FilesPanelProps["onOpenFile"] = file => { + if (previewActive(file)) closeFilePreview(props.instanceId) + else props.onOpenFile(file) + } + let historyBody: HTMLDivElement | undefined + let historyScroll = 0 + const selectCommit = (id: string) => { + historyScroll = historyBody?.scrollTop ?? 0 + void props.history.select(id) + } + const backToCommits = () => { + props.history.back() + requestAnimationFrame(() => { if (historyBody?.isConnected) historyBody.scrollTop = historyScroll }) + } + const commits = createMemo(() => (props.history.page()?.commits ?? []).filter(commit => + `${commit.subject} ${commit.author} ${commit.id}`.toLowerCase().includes(filter().toLowerCase()))) + const handleStageDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => { + if (!droppable) return + const target = String(droppable.id) + if (target !== "staged" && target !== "unstaged") return + const item = items().find(entry => entry.id === String(draggable.id)) + if (!item || item.section === target) return + if (target === "staged") props.git.stageGitFile(item) + else props.git.unstageGitFile(item) + } + const loading = () => props.mode === "workspace" ? props.tree.busy().size > 0 : props.mode === "history" ? props.history.loading() || props.history.detailLoading() : props.git.gitStatusLoading() + const error = () => props.mode === "workspace" ? null : props.mode === "history" ? props.history.error() : props.git.gitStatusError() + const basename = (path: string) => path.replace(/\\/g, "/").split("/").pop() || path + const date = (value: string) => new Date(value).toLocaleDateString(document.documentElement.lang || undefined, { month: "short", day: "numeric" }) + + return
+
+
{props.gitAvailable ? props.branch || props.t("gitPanel.detached") : basename(props.directory)} + +
+ +
+
+
+ + + +
+ + {props.t("gitPanel.files", { count: props.git.gitStatusEntries()?.length ?? 0 })} + +
+ +

{props.t("instanceShell.gitChanges.noSessionSelected")}

+
+ togglePreview({ kind: "workspace", path })} /> +
+
+
+ setFilter(event.currentTarget.value)} /> + {commit => } +

{props.t("gitPanel.emptyHistory")}

+ +
+ + + {details => <> +
{details().id.slice(0, 8)}

{details().message}

{props.t("gitPanel.files", { count: details().files.length })}
+
+ {file => { + const target = () => ({ path: file.path, commit: details().id, subject: details().message.split("\n")[0] }) + return
+ + , + checked: previewActive(target()), disabled: !props.canOpenFile, onSelect: () => togglePreview(target()), + }]} /> +
+ }}
+
+ }
+
+

{props.t("instanceInfo.loading")}

+
+
+ + +

{props.git.gitStatusLoading() ? props.t("instanceInfo.loading") : props.t("instanceShell.gitChanges.empty")}

+ {section => + item.section === section)} + git={props.git} canOpenFile={props.canOpenFile} onOpenFile={togglePreview} previewActive={previewActive}> + +
+ {actionSection => { + const targets = createMemo(() => props.git.gitActionItems().filter(item => item.section === actionSection)) + const action = actionSection === "staged" ? "unstage" : "stage" + return 0}> + }} +
+