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 64f195471..52a48805c 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -1,17 +1,5 @@ -import { - For, - Show, - Suspense, - createEffect, - createMemo, - createSignal, - lazy, - onCleanup, - type Accessor, - type Component, -} from "solid-js" +import { For, Show, Suspense, createEffect, createMemo, createSignal, createUniqueId, type Accessor, type Component } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" -import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" import { DragDropProvider, DragDropSensors, @@ -31,61 +19,25 @@ import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { Session } from "../../../../types/session" import type { PromptInputApi } from "../../../prompt-input/types" import type { DrawerViewState } from "../types" -import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" +import type { RightPanelTab } from "./types" -import { - getDefaultWorktreeSlug, - getGitRepoStatus, - getWorktreeSlugForSession, - getWorktrees, -} from "../../../../stores/worktrees" -import { getRootClient } from "../../../../stores/opencode-client" -import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces" -import { requestData } from "../../../../lib/opencode-api" -import { serverApi } from "../../../../lib/api-client" -import { showConfirmDialog } from "../../../../stores/alerts" -import { showToastNotification } from "../../../../lib/notifications" import { readClientLayoutValue, writeClientLayoutValue } from "../../../../stores/client-state" -import { useGlobalPointerDrag } from "../useGlobalPointerDrag" -import { useGitChanges } from "./useGitChanges" -import { - RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, - RIGHT_PANEL_FILES_WORD_WRAP_KEY, - RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, - RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY, - RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY, - RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, - RIGHT_PANEL_TAB_STORAGE_KEY, - readStoredBool, - readStoredEnum, - readStoredPanelWidth, - readStoredRightPanelTab, -} from "../storage" +import { RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, RIGHT_PANEL_TAB_STORAGE_KEY, readStoredRightPanelTab } from "../storage" import { applyRightPanelItemCustomization, collectRightPanelItems, parseRightPanelCustomization, setRightPanelItemHidden, type RightPanelCustomization, - type RightPanelModule, + type RightPanelItem, type RightPanelSectionModule, type RightPanelTabModule, } from "./registry" +import { createCoreRightPanelRuntime } from "./core-runtime" +import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest" +import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" -const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) -const LazyFilesTab = lazy(() => import("./tabs/FilesTab")) -const LazyStatusTab = lazy(() => import("./tabs/StatusTab")) - function RightPanelTabFallback() { return
} @@ -93,9 +45,13 @@ function RightPanelTabFallback() { interface SortableRightPanelTabProps { tab: RightPanelTabModule active: boolean + tabId: string + panelId: string label: string dragTitle: string + tabIndex: number onSelect: () => void + onKeyDown: (event: KeyboardEvent) => void } const SortableRightPanelTab: Component = (props) => { @@ -105,10 +61,14 @@ const SortableRightPanelTab: Component = (props) => @@ -152,550 +112,14 @@ const RightPanel: Component = (props) => { const [rightPanelCustomization, setRightPanelCustomization] = createSignal( parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), ) - - const [browserPath, setBrowserPath] = createSignal(".") - const [browserEntries, setBrowserEntries] = createSignal(null) - const [browserLoading, setBrowserLoading] = createSignal(false) - const [browserError, setBrowserError] = createSignal(null) - const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null) - const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null) - const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) - const [browserSelectedError, setBrowserSelectedError] = createSignal(null) - const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false) - const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false) - const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal(null) - - 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 [filesWordWrapMode, setFilesWordWrapMode] = createSignal( - readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off", - ) - - const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) - const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320) - const [activeSplitResize, setActiveSplitResize] = createSignal<"git-changes" | "files" | null>(null) - const [splitResizeStartX, setSplitResizeStartX] = createSignal(0) - const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0) - - const [filesListOpen, setFilesListOpen] = createSignal(true) - const [filesListTouched, setFilesListTouched] = createSignal(false) - const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) - const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) - const [gitStagedOpen, setGitStagedOpen] = createSignal(true) - const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true) - - const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) - - const listOpenStorageKey = (tab: "git-changes" | "files") => { - const layout = listLayoutKey() - if (tab === "git-changes") { - return layout === "phone" - ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY - : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY - } - return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY - } - - const gitSectionStorageKey = (section: "staged" | "unstaged") => { - const layout = listLayoutKey() - if (section === "staged") { - return layout === "phone" - ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY - : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY - } - return layout === "phone" - ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY - : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY - } - - const persistListOpen = (tab: "git-changes" | "files", value: boolean) => { - writeClientLayoutValue(listOpenStorageKey(tab), value ? "true" : "false") - } - - const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => { - writeClientLayoutValue(gitSectionStorageKey(section), value ? "true" : "false") - } - - createEffect(() => { - // Refresh persisted visibility when layout changes (phone vs non-phone). - const layout = listLayoutKey() - layout - - const filesPersisted = readStoredBool(listOpenStorageKey("files")) - if (filesPersisted !== null) { - setFilesListOpen(filesPersisted) - setFilesListTouched(true) - } else { - setFilesListOpen(true) - setFilesListTouched(false) - } - - const gitPersisted = readStoredBool(listOpenStorageKey("git-changes")) - if (gitPersisted !== null) { - setGitChangesListOpen(gitPersisted) - setGitChangesListTouched(true) - } else { - setGitChangesListOpen(true) - setGitChangesListTouched(false) - } - - const stagedPersisted = readStoredBool(gitSectionStorageKey("staged")) - setGitStagedOpen(stagedPersisted ?? true) - - const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged")) - setGitUnstagedOpen(unstagedPersisted ?? true) - }) - - createEffect(() => { - // Default behavior: when nothing is selected, keep the file list open. - // Once the user explicitly toggles it, we stop auto-opening. - if (rightPanelTab() !== "files") return - if (filesListTouched()) return - if (!browserSelectedPath()) { - setFilesListOpen(true) - } - }) + const tabGroupId = `right-panel-${createUniqueId()}` + const tabId = (id: string) => `${tabGroupId}-tab-${id}` + const tabPanelId = (id: string) => `${tabGroupId}-panel-${id}` createEffect(() => { writeClientLayoutValue(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) }) - 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()) - }) - - createEffect(() => { - writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode()) - }) - - const clampSplitWidth = (value: number) => { - const min = 200 - const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) - const max = Math.min(560, maxByDrawer) - return Math.min(max, Math.max(min, Math.floor(value))) - } - - const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false) - - createEffect(() => { - if (splitWidthsInitialized()) return - if (!props.rightDrawerWidthInitialized()) return - setSplitWidthsInitialized(true) - setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320))) - setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320))) - }) - - const persistSplitWidth = (mode: "git-changes" | "files", width: number) => { - const key = mode === "git-changes" ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY - writeClientLayoutValue(key, String(width)) - } - - function stopSplitResize() { - setActiveSplitResize(null) - if (typeof document === "undefined") return - splitPointerDrag.stop() - } - - function splitMouseMove(event: MouseEvent) { - const mode = activeSplitResize() - if (!mode) return - event.preventDefault() - const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" - const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1) - const next = clampSplitWidth(splitResizeStartWidth() + delta) - if (mode === "git-changes") setGitChangesSplitWidth(next) - else setFilesSplitWidth(next) - } - - function splitMouseUp() { - const mode = activeSplitResize() - if (mode) { - const width = mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() - persistSplitWidth(mode, width) - } - stopSplitResize() - } - - function splitTouchMove(event: TouchEvent) { - const mode = activeSplitResize() - if (!mode) return - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" - const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1) - const next = clampSplitWidth(splitResizeStartWidth() + delta) - if (mode === "git-changes") setGitChangesSplitWidth(next) - else setFilesSplitWidth(next) - } - - function splitTouchEnd() { - const mode = activeSplitResize() - if (mode) { - const width = mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() - persistSplitWidth(mode, width) - } - stopSplitResize() - } - - const splitPointerDrag = useGlobalPointerDrag({ - onMouseMove: splitMouseMove, - onMouseUp: splitMouseUp, - onTouchMove: splitTouchMove, - onTouchEnd: splitTouchEnd, - }) - - const startSplitResize = (mode: "git-changes" | "files", clientX: number) => { - if (typeof document === "undefined") return - setActiveSplitResize(mode) - setSplitResizeStartX(clientX) - setSplitResizeStartWidth(mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()) - splitPointerDrag.start() - } - - const handleSplitResizeMouseDown = (mode: "git-changes" | "files") => (event: MouseEvent) => { - event.preventDefault() - startSplitResize(mode, event.clientX) - } - - const handleSplitResizeTouchStart = (mode: "git-changes" | "files") => (event: TouchEvent) => { - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - startSplitResize(mode, touch.clientX) - } - - onCleanup(() => { - stopSplitResize() - }) - - const worktreeSlugForViewer = createMemo(() => { - const sessionId = props.activeSessionId() - if (sessionId && sessionId !== "info") { - return getWorktreeSlugForSession(props.instanceId, sessionId) - } - return getDefaultWorktreeSlug(props.instanceId) - }) - - const gitChangesWorktreeSlug = createMemo(() => { - if (getGitRepoStatus(props.instanceId) === false) return null - const slug = worktreeSlugForViewer().trim() - return slug ? slug : null - }) - - const gitChangesWorktree = createMemo(() => { - const slug = gitChangesWorktreeSlug() - if (!slug) return null - return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null - }) - - const gitChangesBranchLabel = createMemo(() => { - const branch = gitChangesWorktree()?.branch?.trim() - return branch || null - }) - - const browserClient = createMemo(() => getRootClient(props.instanceId)) - const fileWorkspacePayload = async () => { - const workspace = await getOpenCodeWorkspaceIdForWorktree(props.instanceId, worktreeSlugForViewer()) - return workspace ? { workspace } : {} - } - - const { - gitStatusEntries, - gitStatusLoading, - gitStatusError, - gitSelectedItemId, - gitBulkSelectedItemIds, - gitSelectedLoading, - gitSelectedError, - gitSelectedBefore, - gitSelectedAfter, - gitCommitMessage, - gitCommitSubmitting, - gitMostChangedItemId, - setGitCommitMessage, - handleGitRowClick, - refreshGitStatus, - insertGitChangeContext, - submitGitCommit, - stageGitFile, - unstageGitFile, - } = useGitChanges({ - t: props.t, - instanceId: props.instanceId, - rightPanelTab, - worktreeSlug: worktreeSlugForViewer, - isPhoneLayout: props.isPhoneLayout, - promptInputApi: props.promptInputApi, - closeGitList: () => setGitChangesListOpen(false), - }) - - createEffect(() => { - worktreeSlugForViewer() - setBrowserPath(".") - setBrowserEntries(null) - setBrowserError(null) - setBrowserSelectedPath(null) - setBrowserSelectedContent(null) - setBrowserSelectedError(null) - setBrowserSelectedLoading(false) - }) - - const normalizeBrowserPath = (input: string) => { - const raw = String(input || ".").trim() - if (!raw || raw === "./") return "." - const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "") - return cleaned === "" ? "." : cleaned - } - - const getParentPath = (path: string): string | null => { - const current = normalizeBrowserPath(path) - if (current === ".") return null - const parts = current.split("/").filter(Boolean) - parts.pop() - return parts.length ? parts.join("/") : "." - } - - const loadBrowserEntries = async (path: string) => { - const normalized = normalizeBrowserPath(path) - setBrowserLoading(true) - setBrowserError(null) - try { - const nodes = await requestData(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "file.list") - setBrowserPath(normalized) - setBrowserEntries(Array.isArray(nodes) ? nodes : []) - } catch (error) { - setBrowserError(error instanceof Error ? error.message : "Failed to load files") - setBrowserEntries([]) - } finally { - setBrowserLoading(false) - } - } - - const openBrowserFile = async (path: string) => { - setBrowserSelectedPath(path) - setBrowserSelectedLoading(true) - setBrowserSelectedError(null) - setBrowserSelectedContent(null) - setBrowserSelectedDirty(false) - setBrowserSelectedOriginalContent(null) - - // Phone: treat file selection as a commit action and close the overlay. - if (props.isPhoneLayout()) { - setFilesListOpen(false) - } - try { - const content = await requestData(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const text = (content as any)?.content - if (typeof text !== "string") { - throw new Error("Unsupported file type") - } - setBrowserSelectedContent(text) - setBrowserSelectedOriginalContent(text) // Track original content for conflict detection - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") - } finally { - setBrowserSelectedLoading(false) - } - } - - const saveBrowserFile = async (content: string): Promise => { - const path = browserSelectedPath() - if (!path) return false - - // Check for conflict: agent edited file while user was editing - const originalContent = browserSelectedOriginalContent() - if (originalContent !== null) { - try { - const currentDiskContent = await requestData( - browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), - "file.read", - ) - const diskContent = (currentDiskContent as any)?.content - - // If disk content differs from what we originally loaded (agent edit) - // AND differs from user's current edits, we have a conflict - if (diskContent !== originalContent && diskContent !== content) { - const confirmed = await showConfirmDialog( - props.t("instanceShell.rightPanel.actions.conflict.message", { path }), - { - variant: "warning", - confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"), - cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"), - dismissible: false, - }, - ) - if (!confirmed) { - return false - } - // User chose to overwrite, proceed with save - } - } catch { - // If we can't check for conflict, proceed with save - } - } - - setBrowserSelectedSaving(true) - try { - await serverApi.writeWorkspaceFile(props.instanceId, path, content, { worktree: worktreeSlugForViewer() }) - setBrowserSelectedContent(content) - setBrowserSelectedOriginalContent(content) // Update original to match saved - setBrowserSelectedDirty(false) - showToastNotification({ - message: props.t("instanceShell.rightPanel.toast.saveSuccess"), - variant: "success", - }) - return true - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file") - showToastNotification({ - message: props.t("instanceShell.rightPanel.toast.saveError"), - variant: "error", - }) - return false - } finally { - setBrowserSelectedSaving(false) - } - } - - const handleBrowserFileChange = (content: string) => { - setBrowserSelectedContent(content) - setBrowserSelectedDirty(true) - } - - const handleOpenBrowserFileRequest = async (path: string) => { - if (browserSelectedDirty()) { - const confirmed = await showConfirmDialog( - props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }), - { - variant: "warning", - confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"), - cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"), - dismissible: false, - }, - ) - if (confirmed) { - const saveSuccess = await saveBrowserFile(browserSelectedContent() || "") - if (!saveSuccess) { - // Save failed - stay on current file, error toast already shown - return - } - } else { - // User chose not to save - clear dirty state and discard edits - setBrowserSelectedDirty(false) - } - } - await openBrowserFile(path) - } - - createEffect(() => { - if (rightPanelTab() !== "files") return - if (browserLoading()) return - if (browserEntries() !== null) return - void loadBrowserEntries(browserPath()) - }) - - createEffect(() => { - if (rightPanelTab() === "files") return - setBrowserSelectedContent(null) - setBrowserSelectedLoading(false) - setBrowserSelectedError(null) - setBrowserSelectedDirty(false) - }) - - const toggleFilesList = () => { - setFilesListTouched(true) - setFilesListOpen((current) => { - const next = !current - persistListOpen("files", next) - return next - }) - } - - const toggleGitList = () => { - setGitChangesListTouched(true) - setGitChangesListOpen((current) => { - const next = !current - persistListOpen("git-changes", next) - return next - }) - } - - const refreshFilesTab = async () => { - // Prompt for confirmation if file has unsaved changes - if (browserSelectedDirty()) { - const confirmed = await showConfirmDialog( - props.t("instanceShell.rightPanel.actions.refreshDirty.message"), - { - variant: "warning", - confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"), - cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"), - dismissible: false, - }, - ) - if (!confirmed) { - return - } - } - - void loadBrowserEntries(browserPath()) - const selected = browserSelectedPath() - if (selected) { - // Refresh file content without altering overlay state. - setBrowserSelectedLoading(true) - setBrowserSelectedError(null) - try { - const content = await requestData(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const text = (content as any)?.content - if (typeof text !== "string") { - throw new Error("Unsupported file type") - } - setBrowserSelectedContent(text) - setBrowserSelectedOriginalContent(text) // Update original content after refresh - setBrowserSelectedDirty(false) // Clear dirty after refresh - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") - } finally { - setBrowserSelectedLoading(false) - } - } - } - - const browserParentPath = createMemo(() => getParentPath(browserPath())) - const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`) - const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`) - const handleAccordionChange = (values: string[]) => { setRightPanelExpandedItems(values) } @@ -727,129 +151,69 @@ const RightPanel: Component = (props) => { moveTab(String(draggable.id), String(droppable.id)) } - const rightPanelModules = createMemo(() => [ + const openRightPanelTab = (tabId: string) => { + updateRightPanelCustomization((current) => ({ + ...current, + hiddenTabIds: current.hiddenTabIds.filter((id) => id !== tabId), + })) + setRightPanelTab(tabId) + } + + const handleTabKeyDown = (event: KeyboardEvent, currentTabId: string) => { + const tabs = visibleRightPanelTabs() + const index = tabs.findIndex((tab) => tab.id === currentTabId) + if (index === -1) return + + let target: RightPanelTabModule | undefined + if (event.key === "ArrowLeft") target = tabs[(index - 1 + tabs.length) % tabs.length] + if (event.key === "ArrowRight") target = tabs[(index + 1) % tabs.length] + if (event.key === "Home") target = tabs[0] + if (event.key === "End") target = tabs[tabs.length - 1] + if (!target) return + + event.preventDefault() + setRightPanelTab(target.id) + queueMicrotask(() => document.getElementById(tabId(target.id))?.focus()) + } + + const rightPanelPluginRuntime = loadRightPanelPluginManifests( + [ + createCoreRightPanelRuntime({ + t: props.t, + instanceId: props.instanceId, + instance: props.instance, + activeSessionId: props.activeSessionId, + activeSession: props.activeSession, + latestTodoState: props.latestTodoState, + backgroundProcessList: props.backgroundProcessList, + onOpenBackgroundOutput: props.onOpenBackgroundOutput, + onStopBackgroundProcess: props.onStopBackgroundProcess, + onTerminateBackgroundProcess: props.onTerminateBackgroundProcess, + isPhoneLayout: props.isPhoneLayout, + rightDrawerWidth: props.rightDrawerWidth, + rightDrawerWidthInitialized: props.rightDrawerWidthInitialized, + promptInputApi: props.promptInputApi, + rightPanelTab, + expandedItems: rightPanelExpandedItems, + onExpandedItemsChange: handleAccordionChange, + customization: rightPanelCustomization, + onCustomizationChange: updateRightPanelCustomization, + extraStatusSections: () => extraStatusSections(), + }), + ...RIGHT_PANEL_PLUGIN_MANIFESTS, + ], { - id: "core-right-panel", - tabs: [ - { - id: "git-changes", - labelKey: "instanceShell.rightPanel.tabs.gitChanges", - order: 10, - render: () => ( - void refreshGitStatus()} - onInsertContext={insertGitChangeContext} - onStageFile={stageGitFile} - onUnstageFile={unstageGitFile} - commitMessage={gitCommitMessage} - commitSubmitting={gitCommitSubmitting} - onCommitMessageInput={setGitCommitMessage} - onSubmitCommit={() => void 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={gitChangesSplitWidth} - onResizeMouseDown={handleSplitResizeMouseDown("git-changes")} - onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} - isPhoneLayout={props.isPhoneLayout} - /> - ), - }, - { - id: "files", - labelKey: "instanceShell.rightPanel.tabs.files", - order: 20, - render: () => ( - void loadBrowserEntries(path)} - onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} - onRefresh={() => void refreshFilesTab()} - onSave={(content: string) => void saveBrowserFile(content)} - onContentChange={(content: string) => handleBrowserFileChange(content)} - onWordWrapModeChange={setFilesWordWrapMode} - listOpen={filesListOpen} - onToggleList={toggleFilesList} - splitWidth={filesSplitWidth} - onResizeMouseDown={handleSplitResizeMouseDown("files")} - onResizeTouchStart={handleSplitResizeTouchStart("files")} - isPhoneLayout={props.isPhoneLayout} - /> - ), - }, - { - id: "status", - labelKey: "instanceShell.rightPanel.tabs.status", - order: 30, - render: () => ( - - ), - }, - ], + instanceId: props.instanceId, + t: props.t, + activeSessionId: props.activeSessionId, + isTabActive: (tabId) => rightPanelTab() === tabId, + openTab: openRightPanelTab, + reportAttention: () => undefined, }, - ]) + ) + const rightPanelModules = createMemo(() => rightPanelPluginRuntime.modules) + const rightPanelPluginErrors = createMemo(() => rightPanelPluginRuntime.errors) const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) const visibleRightPanelTabs = createMemo(() => applyRightPanelItemCustomization( @@ -860,7 +224,7 @@ const RightPanel: Component = (props) => { ) const orderedRightPanelTabs = createMemo(() => applyRightPanelItemCustomization(allRightPanelTabs(), rightPanelCustomization().tabOrder, [])) const extraStatusSections = createMemo(() => collectRightPanelItems(rightPanelModules(), "statusSections")) - const allStatusSections = createMemo(() => [...CORE_STATUS_SECTION_ITEMS, ...extraStatusSections()]) + const allStatusSections = createMemo(() => [...CORE_STATUS_SECTION_ITEMS, ...extraStatusSections()]) const orderedStatusSections = createMemo(() => applyRightPanelItemCustomization(allStatusSections(), rightPanelCustomization().statusSectionOrder, [])) const visibleStatusSections = createMemo(() => applyRightPanelItemCustomization( @@ -926,9 +290,13 @@ const RightPanel: Component = (props) => { setRightPanelTab(tab.id)} + onKeyDown={(event) => handleTabKeyDown(event, tab.id)} /> )} @@ -945,35 +313,19 @@ const RightPanel: Component = (props) => {
- {(tab) => }>{tab.render()}} + {(tab) => ( +
+ }>{tab.render()} +
+ )}
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 new file mode 100644 index 000000000..20fd18c77 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -0,0 +1,81 @@ +import type { JSX } from "solid-js" + +import type { RightPanelManifest } from "./plugin-manifest" +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 +} + +interface CoreStatusSectionRenderers { + renderYoloModeSection: () => JSX.Element + renderProviderUsage: () => JSX.Element + renderPlanSectionContent: () => JSX.Element + renderBackgroundProcesses: () => JSX.Element + renderMcpStatus: () => JSX.Element + renderLspStatus: () => JSX.Element + renderPluginStatus: () => JSX.Element +} + +export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers): RightPanelManifest { + return { + id: "core-right-panel", + displayNameKey: "instanceShell.rightPanel.modules.core", + descriptionKey: "instanceShell.rightPanel.modules.core.description", + origin: "first-party", + create: () => ({ + id: "core-right-panel", + displayNameKey: "instanceShell.rightPanel.modules.core", + 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", + order: 20, + render: renderers.renderFilesTab, + }, + { + id: "status", + labelKey: "instanceShell.rightPanel.tabs.status", + order: 30, + alwaysVisible: true, + render: renderers.renderStatusTab, + }, + ], + }), + } +} + +export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRenderers): RightPanelModule { + const sectionRenderers: Record JSX.Element> = { + "yolo-mode": renderers.renderYoloModeSection, + "provider-usage": renderers.renderProviderUsage, + plan: renderers.renderPlanSectionContent, + "background-processes": renderers.renderBackgroundProcesses, + mcp: renderers.renderMcpStatus, + lsp: renderers.renderLspStatus, + plugins: renderers.renderPluginStatus, + } + + return { + id: "core-status-sections", + displayNameKey: "instanceShell.rightPanel.modules.core", + descriptionKey: "instanceShell.rightPanel.modules.core.description", + origin: "first-party", + statusSections: CORE_STATUS_SECTION_ITEMS.map((section) => { + const render = sectionRenderers[section.id] + if (!render) throw new Error(`Missing core right panel section renderer: ${section.id}`) + return { ...section, render } + }), + } +} 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 new file mode 100644 index 000000000..c7be8ecce --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx @@ -0,0 +1,245 @@ +import { createEffect, createMemo, createSignal, lazy, type Accessor } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk/v2" + +import type { Instance } from "../../../../types/instance" +import type { BackgroundProcess } from "../../../../../../server/src/api-types" +import type { Session } from "../../../../types/session" +import type { PromptInputApi } from "../../../prompt-input/types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, 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 { useGitChanges } from "./useGitChanges" +import { createCoreRightPanelManifest } from "./core-plugin" +import { createFilesTabRuntime } from "./tabs/files-runtime" +import { createSplitResize } from "./tabs/split-resize" + +const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) +const LazyStatusTab = lazy(() => import("./tabs/StatusTab")) + +interface CoreRightPanelRuntimeOptions { + t: (key: string, vars?: Record) => string + instanceId: string + instance: Instance + activeSessionId: Accessor + activeSession: Accessor + latestTodoState: Accessor + backgroundProcessList: Accessor + onOpenBackgroundOutput: (process: BackgroundProcess) => void + onStopBackgroundProcess: (processId: string) => Promise | void + onTerminateBackgroundProcess: (processId: string) => Promise | void + isPhoneLayout: Accessor + rightDrawerWidth: Accessor + rightDrawerWidthInitialized: Accessor + promptInputApi: Accessor + rightPanelTab: Accessor + expandedItems: Accessor + onExpandedItemsChange: (values: string[]) => void + customization: Accessor + onCustomizationChange: (updater: (current: RightPanelCustomization) => RightPanelCustomization) => void + extraStatusSections: Accessor +} + +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 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 + }) + + const gitChangesWorktree = createMemo(() => { + const slug = gitChangesWorktreeSlug() + if (!slug) return null + return getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug) ?? null + }) + + const gitChangesBranchLabel = createMemo(() => gitChangesWorktree()?.branch?.trim() || null) + const gitScopeKey = createMemo(() => `${options.instanceId}:git:${worktreeSlugForViewer()}`) + const git = useGitChanges({ + t: options.t, + instanceId: options.instanceId, + rightPanelTab: options.rightPanelTab, + worktreeSlug: worktreeSlugForViewer, + isPhoneLayout: options.isPhoneLayout, + promptInputApi: options.promptInputApi, + closeGitList: () => setGitChangesListOpen(false), + }) + 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 + }) + } + + 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: () => ( + + ), + }) +} 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 new file mode 100644 index 000000000..7aa0f7f58 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { createCoreRightPanelManifest, createCoreStatusSectionManifest } from "./core-plugin" +import { loadRightPanelPluginManifests, type RightPanelHostContext, type RightPanelManifest } from "./plugin-manifest" + +const host: RightPanelHostContext = { + instanceId: "abc", + t: (key) => key, + activeSessionId: () => "session-1", + isTabActive: () => false, + openTab: () => {}, +} + +const manifest = (id: string, events: string[]): RightPanelManifest => ({ + id, + displayNameKey: id, + origin: "first-party", + create: (context) => { + events.push(`${id}:create:${context.instanceId}:${context.activeSessionId()}`) + return { + id, + displayNameKey: id, + origin: "first-party", + tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }], + } + }, +}) + +describe("right panel plugin manifests", () => { + it("creates modules with host context", () => { + const events: string[] = [] + const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], host) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["first", "second"]) + assert.deepEqual(events, ["first:create:abc:session-1", "second:create:abc:session-1"]) + }) + + it("skips duplicate ids without blocking other plugins", () => { + const events: string[] = [] + const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], host) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["plugin", "other"]) + assert.equal(runtime.errors.length, 1) + assert.equal(runtime.errors[0]?.pluginId, "plugin") + }) + + it("skips plugins that fail during load", () => { + const runtime = loadRightPanelPluginManifests( + [ + { id: "bad", displayNameKey: "bad", origin: "first-party", create: () => { throw new Error("boom") } }, + manifest("good", []), + ], + host, + ) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["good"]) + assert.equal(runtime.errors.length, 1) + assert.equal(runtime.errors[0]?.pluginId, "bad") + }) + + 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, + }) + const statusSections = createCoreStatusSectionManifest({ + renderYoloModeSection: render, + renderProviderUsage: render, + renderPlanSectionContent: render, + renderBackgroundProcesses: render, + renderMcpStatus: render, + renderLspStatus: render, + renderPluginStatus: render, + }) + + const rightPanelModule = rightPanel.create(host) + + assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"]) + assert.equal(rightPanelModule.tabs?.find((entry) => entry.id === "status")?.alwaysVisible, true) + assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [ + "yolo-mode", + "provider-usage", + "plan", + "background-processes", + "mcp", + "lsp", + "plugins", + ]) + }) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts new file mode 100644 index 000000000..b5337f94d --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts @@ -0,0 +1,75 @@ +import type { Accessor } from "solid-js" +import type { RightPanelModule } from "./registry" + +export interface RightPanelHostContext { + instanceId: string + t: (key: string, vars?: Record) => string + activeSessionId: Accessor + isTabActive: (tabId: string) => boolean + openTab: (tabId: string) => void + reportAttention?: (attention: RightPanelAttention) => void +} + +export interface RightPanelAttention { + moduleId: string + tabId?: string + messageKey: string + severity: "info" | "warning" | "critical" +} + +export interface RightPanelManifest { + id: string + displayNameKey: string + descriptionKey?: string + origin: "first-party" + create: (host: RightPanelHostContext) => RightPanelModule +} + +export interface RightPanelPluginLoadError { + pluginId: string + displayNameKey?: string + phase: "create" + error: unknown +} + +export interface LoadedRightPanelPlugins { + modules: RightPanelModule[] + errors: RightPanelPluginLoadError[] +} + +export function loadRightPanelPluginManifests( + manifests: readonly RightPanelManifest[], + context: RightPanelHostContext, +): LoadedRightPanelPlugins { + const modules: RightPanelModule[] = [] + const errors: RightPanelPluginLoadError[] = [] + const seen = new Set() + + for (const manifest of manifests) { + if (!manifest.id || seen.has(manifest.id)) { + errors.push({ + pluginId: manifest.id || "", + displayNameKey: manifest.displayNameKey, + phase: "create", + error: new Error("Duplicate or missing right panel plugin id"), + }) + continue + } + seen.add(manifest.id) + + try { + const module = manifest.create(context) + modules.push({ + ...module, + id: manifest.id, + displayNameKey: manifest.displayNameKey, + descriptionKey: manifest.descriptionKey ?? module.descriptionKey, + origin: manifest.origin, + }) + } catch (error) { + errors.push({ pluginId: manifest.id, displayNameKey: manifest.displayNameKey, phase: "create", error }) + } + } + + return { modules, errors } +} diff --git a/packages/ui/src/components/instance/shell/right-panel/plugins.ts b/packages/ui/src/components/instance/shell/right-panel/plugins.ts new file mode 100644 index 000000000..372530ecf --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugins.ts @@ -0,0 +1,3 @@ +import type { RightPanelManifest } from "./plugin-manifest" + +export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelManifest[] = [] diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.test.ts b/packages/ui/src/components/instance/shell/right-panel/registry.test.ts index a7b39d015..8575b053d 100644 --- a/packages/ui/src/components/instance/shell/right-panel/registry.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/registry.test.ts @@ -8,18 +8,20 @@ import { parseRightPanelCustomization, setRightPanelItemHidden, type RightPanelItem, + type RightPanelModule, type RightPanelTabModule, } from "./registry" const item = (id: string, order: number, alwaysVisible = false): RightPanelItem => ({ id, labelKey: id, order, alwaysVisible }) const tab = (id: string, order: number): RightPanelTabModule => ({ ...item(id, order), render: () => undefined as any }) +const module = (id: string, tabs: RightPanelTabModule[]): RightPanelModule => ({ id, displayNameKey: id, origin: "first-party", tabs }) describe("right panel registry", () => { it("collects and orders module items", () => { const items = collectRightPanelItems( [ - { id: "core", tabs: [tab("status", 40), tab("changes", 10)] }, - { id: "plugin", tabs: [tab("custom", 30)] }, + module("core", [tab("status", 40), tab("changes", 10)]), + module("plugin", [tab("custom", 30)]), ], "tabs", ) @@ -28,7 +30,7 @@ describe("right panel registry", () => { }) it("rejects duplicate item ids", () => { - assert.throws(() => collectRightPanelItems([{ id: "core", tabs: [tab("status", 10), tab("status", 20)] }], "tabs")) + assert.throws(() => collectRightPanelItems([module("core", [tab("status", 10), tab("status", 20)])], "tabs")) }) it("applies visibility and user order", () => { diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.ts b/packages/ui/src/components/instance/shell/right-panel/registry.ts index c24077eac..47aba6356 100644 --- a/packages/ui/src/components/instance/shell/right-panel/registry.ts +++ b/packages/ui/src/components/instance/shell/right-panel/registry.ts @@ -19,6 +19,9 @@ export interface RightPanelSectionModule extends RightPanelItem { export interface RightPanelModule { id: string + displayNameKey: string + descriptionKey?: string + origin: "first-party" tabs?: readonly RightPanelTabModule[] statusSections?: readonly RightPanelSectionModule[] } diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 84baefd15..33d1b55d0 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -25,6 +25,7 @@ import InstanceServiceStatus from "../../../../instance-service-status" import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances" import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" +import { createCoreStatusSectionManifest } from "../core-plugin" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -227,66 +228,28 @@ const StatusTab: Component = (props) => { ) } - const statusSections = createMemo(() => { - const sections: RightPanelSectionModule[] = [ - { - id: "yolo-mode", - labelKey: "instanceShell.rightPanel.sections.yoloMode", - tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip", - order: 10, - render: renderYoloModeSection, - }, - { - id: "provider-usage", - labelKey: "providerUsage.title", - tooltipKey: "providerUsage.tooltip", - order: 20, - render: renderProviderUsage, - }, - { - id: "plan", - labelKey: "instanceShell.rightPanel.sections.plan", - tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", - order: 30, - render: renderPlanSectionContent, - }, - { - id: "background-processes", - labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", - tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", - order: 40, - render: renderBackgroundProcesses, - }, - { - id: "mcp", - labelKey: "instanceShell.rightPanel.sections.mcp", - tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip", - order: 50, - render: () => , - }, - { - id: "lsp", - labelKey: "instanceShell.rightPanel.sections.lsp", - tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip", - order: 60, - render: () => , - }, - { - id: "plugins", - labelKey: "instanceShell.rightPanel.sections.plugins", - tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip", - order: 70, - render: () => ( - - ), - }, - ] - return applyRightPanelItemCustomization( - [...sections, ...(props.extraSections ?? [])], + const allStatusSections = createMemo(() => { + const sections = createCoreStatusSectionManifest({ + renderYoloModeSection, + renderProviderUsage, + renderPlanSectionContent, + renderBackgroundProcesses, + renderMcpStatus: () => , + renderLspStatus: () => , + renderPluginStatus: () => ( + + ), + }).statusSections ?? [] + + return [...sections, ...(props.extraSections ?? [])] + }) + const statusSections = createMemo(() => + applyRightPanelItemCustomization( + allStatusSections(), props.customization().statusSectionOrder, props.customization().hiddenStatusSectionIds, - ) - }) + ), + ) const moveSection = (sourceId: string, targetId: string) => { if (!sourceId || sourceId === targetId) return diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx new file mode 100644 index 000000000..462b20a1e --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -0,0 +1,316 @@ +import { createEffect, createMemo, createSignal, lazy, type Accessor, type JSX } from "solid-js" +import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" + +import type { DiffWordWrapMode, RightPanelTab } from "../types" + +import { getRootClient } from "../../../../../stores/opencode-client" +import { getOpenCodeWorkspaceIdForWorktree } from "../../../../../stores/opencode-workspaces" +import { requestData } from "../../../../../lib/opencode-api" +import { serverApi } from "../../../../../lib/api-client" +import { showConfirmDialog } from "../../../../../stores/alerts" +import { showToastNotification } from "../../../../../lib/notifications" +import { writeClientLayoutValue } from "../../../../../stores/client-state" +import { + RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_FILES_WORD_WRAP_KEY, + readStoredBool, + readStoredEnum, +} from "../../storage" +import { createSplitResize } from "./split-resize" + +const LazyFilesTab = lazy(() => import("./FilesTab")) + +interface FilesTabRuntimeOptions { + t: (key: string, vars?: Record) => string + instanceId: string + rightPanelTab: Accessor + worktreeSlug: Accessor + isPhoneLayout: Accessor + rightDrawerWidth: Accessor + rightDrawerWidthInitialized: Accessor +} + +export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JSX.Element { + const [browserPath, setBrowserPath] = createSignal(".") + const [browserEntries, setBrowserEntries] = createSignal(null) + const [browserLoading, setBrowserLoading] = createSignal(false) + const [browserError, setBrowserError] = createSignal(null) + const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null) + const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null) + const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) + const [browserSelectedError, setBrowserSelectedError] = createSignal(null) + const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false) + const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false) + const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal(null) + const [filesWordWrapMode, setFilesWordWrapMode] = createSignal( + readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off", + ) + const [filesListOpen, setFilesListOpen] = createSignal(true) + const [filesListTouched, setFilesListTouched] = createSignal(false) + const browserClient = createMemo(() => getRootClient(options.instanceId)) + const filesSplit = createSplitResize({ + storageKey: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, + defaultWidth: 320, + rightDrawerWidth: options.rightDrawerWidth, + rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, + }) + + const filesListOpenStorageKey = createMemo(() => + options.isPhoneLayout() ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, + ) + + const fileWorkspacePayload = async () => { + const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, options.worktreeSlug()) + return workspace ? { workspace } : {} + } + + createEffect(() => { + filesListOpenStorageKey() + const persisted = readStoredBool(filesListOpenStorageKey()) + if (persisted !== null) { + setFilesListOpen(persisted) + setFilesListTouched(true) + } else { + setFilesListOpen(true) + setFilesListTouched(false) + } + }) + + createEffect(() => { + if (options.rightPanelTab() !== "files") return + if (filesListTouched()) return + if (!browserSelectedPath()) setFilesListOpen(true) + }) + + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode())) + + createEffect(() => { + options.worktreeSlug() + setBrowserPath(".") + setBrowserEntries(null) + setBrowserError(null) + setBrowserSelectedPath(null) + setBrowserSelectedContent(null) + setBrowserSelectedError(null) + setBrowserSelectedLoading(false) + }) + + const normalizeBrowserPath = (input: string) => { + const raw = String(input || ".").trim() + if (!raw || raw === "./") return "." + const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "") + return cleaned === "" ? "." : cleaned + } + + const getParentPath = (path: string): string | null => { + const current = normalizeBrowserPath(path) + if (current === ".") return null + const parts = current.split("/").filter(Boolean) + parts.pop() + return parts.length ? parts.join("/") : "." + } + + const loadBrowserEntries = async (path: string) => { + const normalized = normalizeBrowserPath(path) + setBrowserLoading(true) + setBrowserError(null) + try { + const nodes = await requestData(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "file.list") + setBrowserPath(normalized) + setBrowserEntries(Array.isArray(nodes) ? nodes : []) + } catch (error) { + setBrowserError(error instanceof Error ? error.message : "Failed to load files") + setBrowserEntries([]) + } finally { + setBrowserLoading(false) + } + } + + const openBrowserFile = async (path: string) => { + setBrowserSelectedPath(path) + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + setBrowserSelectedContent(null) + setBrowserSelectedDirty(false) + setBrowserSelectedOriginalContent(null) + + if (options.isPhoneLayout()) setFilesListOpen(false) + try { + const content = await requestData(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") throw new Error("Binary file cannot be displayed") + if (encoding === "base64") throw new Error("Binary file cannot be displayed") + const text = (content as any)?.content + if (typeof text !== "string") throw new Error("Unsupported file type") + setBrowserSelectedContent(text) + setBrowserSelectedOriginalContent(text) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + + const saveBrowserFile = async (content: string): Promise => { + const path = browserSelectedPath() + if (!path) return false + + const originalContent = browserSelectedOriginalContent() + if (originalContent !== null) { + try { + const currentDiskContent = await requestData( + browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), + "file.read", + ) + const diskContent = (currentDiskContent as any)?.content + if (diskContent !== originalContent && diskContent !== content) { + const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.conflict.message", { path }), { + variant: "warning", + confirmLabel: options.t("instanceShell.rightPanel.actions.conflict.confirmLabel"), + cancelLabel: options.t("instanceShell.rightPanel.actions.conflict.cancelLabel"), + dismissible: false, + }) + if (!confirmed) return false + } + } catch { + // If conflict detection fails, keep the existing behavior and try the save. + } + } + + setBrowserSelectedSaving(true) + try { + await serverApi.writeWorkspaceFile(options.instanceId, path, content, { worktree: options.worktreeSlug() }) + setBrowserSelectedContent(content) + setBrowserSelectedOriginalContent(content) + setBrowserSelectedDirty(false) + showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveSuccess"), variant: "success" }) + return true + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file") + showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveError"), variant: "error" }) + return false + } finally { + setBrowserSelectedSaving(false) + } + } + + const handleOpenBrowserFileRequest = async (path: string) => { + if (browserSelectedDirty()) { + const confirmed = await showConfirmDialog( + options.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }), + { + variant: "warning", + confirmLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"), + cancelLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"), + dismissible: false, + }, + ) + if (confirmed) { + const saveSuccess = await saveBrowserFile(browserSelectedContent() || "") + if (!saveSuccess) return + } else { + setBrowserSelectedDirty(false) + } + } + await openBrowserFile(path) + } + + createEffect(() => { + if (options.rightPanelTab() !== "files") return + if (browserLoading()) return + if (browserEntries() !== null) return + void loadBrowserEntries(browserPath()) + }) + + createEffect(() => { + if (options.rightPanelTab() === "files") return + setBrowserSelectedContent(null) + setBrowserSelectedLoading(false) + setBrowserSelectedError(null) + setBrowserSelectedDirty(false) + }) + + const toggleFilesList = () => { + setFilesListTouched(true) + setFilesListOpen((current) => { + const next = !current + writeClientLayoutValue(filesListOpenStorageKey(), next ? "true" : "false") + return next + }) + } + + const refreshFilesTab = async () => { + if (browserSelectedDirty()) { + const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.refreshDirty.message"), { + variant: "warning", + confirmLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"), + cancelLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"), + dismissible: false, + }) + if (!confirmed) return + } + + void loadBrowserEntries(browserPath()) + const selected = browserSelectedPath() + if (!selected) return + + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + try { + const content = await requestData(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") throw new Error("Binary file cannot be displayed") + if (encoding === "base64") throw new Error("Binary file cannot be displayed") + const text = (content as any)?.content + if (typeof text !== "string") throw new Error("Unsupported file type") + setBrowserSelectedContent(text) + setBrowserSelectedOriginalContent(text) + setBrowserSelectedDirty(false) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + + const browserParentPath = createMemo(() => getParentPath(browserPath())) + const browserScopeKey = createMemo(() => `${options.instanceId}:${options.worktreeSlug()}`) + + return () => ( + void loadBrowserEntries(path)} + onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} + onRefresh={() => void refreshFilesTab()} + onSave={(content: string) => void saveBrowserFile(content)} + onContentChange={(content: string) => { + setBrowserSelectedContent(content) + setBrowserSelectedDirty(true) + }} + onWordWrapModeChange={setFilesWordWrapMode} + listOpen={filesListOpen} + onToggleList={toggleFilesList} + splitWidth={filesSplit.splitWidth} + onResizeMouseDown={filesSplit.onResizeMouseDown} + onResizeTouchStart={filesSplit.onResizeTouchStart} + isPhoneLayout={options.isPhoneLayout} + /> + ) +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts b/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts new file mode 100644 index 000000000..8f3a8cad7 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts @@ -0,0 +1,98 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +import { writeClientLayoutValue } from "../../../../../stores/client-state" +import { readStoredPanelWidth } from "../../storage" +import { useGlobalPointerDrag } from "../../useGlobalPointerDrag" + +interface SplitResizeOptions { + storageKey: string + defaultWidth: number + rightDrawerWidth: Accessor + rightDrawerWidthInitialized: Accessor +} + +export function createSplitResize(options: SplitResizeOptions) { + const [splitWidth, setSplitWidth] = createSignal(options.defaultWidth) + const [initialized, setInitialized] = createSignal(false) + const [active, setActive] = createSignal(false) + const [startX, setStartX] = createSignal(0) + const [startWidth, setStartWidth] = createSignal(0) + + const clampSplitWidth = (value: number) => { + const min = 200 + const maxByDrawer = Math.max(min, Math.floor(options.rightDrawerWidth() * 0.65)) + const max = Math.min(560, maxByDrawer) + return Math.min(max, Math.max(min, Math.floor(value))) + } + + createEffect(() => { + if (initialized()) return + if (!options.rightDrawerWidthInitialized()) return + setInitialized(true) + setSplitWidth(clampSplitWidth(readStoredPanelWidth(options.storageKey, options.defaultWidth))) + }) + + const persistSplitWidth = () => { + writeClientLayoutValue(options.storageKey, String(splitWidth())) + } + + function stopResize() { + setActive(false) + if (typeof document === "undefined") return + pointerDrag.stop() + } + + function move(clientX: number) { + if (!active()) return + const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" + const delta = (clientX - startX()) * (isRtl ? -1 : 1) + setSplitWidth(clampSplitWidth(startWidth() + delta)) + } + + const pointerDrag = useGlobalPointerDrag({ + onMouseMove: (event) => { + if (!active()) return + event.preventDefault() + move(event.clientX) + }, + onMouseUp: () => { + if (active()) persistSplitWidth() + stopResize() + }, + onTouchMove: (event) => { + if (!active()) return + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + move(touch.clientX) + }, + onTouchEnd: () => { + if (active()) persistSplitWidth() + stopResize() + }, + }) + + const startResize = (clientX: number) => { + if (typeof document === "undefined") return + setActive(true) + setStartX(clientX) + setStartWidth(splitWidth()) + pointerDrag.start() + } + + onCleanup(stopResize) + + return { + splitWidth, + onResizeMouseDown: (event: MouseEvent) => { + event.preventDefault() + startResize(event.clientX) + }, + onResizeTouchStart: (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startResize(touch.clientX) + }, + } +} diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index 5b5e9018c..d3eaa2635 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Dateien", "instanceShell.rightPanel.tabs.status": "Status", "instanceShell.rightPanel.tabs.ariaLabel": "Tabs im rechten Panel", + "instanceShell.rightPanel.modules.core": "CodeNomad-Kern", + "instanceShell.rightPanel.modules.core.description": "Integrierte Git-, Dateien- und Status-Panels.", "instanceShell.rightPanel.customize.toggle": "Rechtes Panel anpassen", "instanceShell.rightPanel.customize.title": "Rechtes Panel anpassen", - "instanceShell.rightPanel.customize.description": "Tabs und Statusabschnitte anzeigen oder ausblenden.", + "instanceShell.rightPanel.customize.description": "Module des rechten Panels anzeigen oder ausblenden.", "instanceShell.rightPanel.customize.reset": "Zurücksetzen", "instanceShell.rightPanel.customize.tabs": "Tabs", + "instanceShell.rightPanel.customize.sections": "Abschnitte anpassen", "instanceShell.rightPanel.customize.statusSections": "Statusabschnitte", - "instanceShell.rightPanel.customize.dragToReorder": "Tabs oder Statusabschnitte ziehen, um sie neu anzuordnen.", + "instanceShell.rightPanel.customize.dragToReorder": "Ziehen zum Neuordnen.", + "instanceShell.rightPanel.customize.alwaysVisible": "Immer sichtbar", + "instanceShell.rightPanel.customize.unavailableModules": "Nicht verfügbare Module", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} nicht verfügbar", "instanceShell.rightPanel.customize.moveUp": "Nach oben", "instanceShell.rightPanel.customize.moveDown": "Nach unten", "instanceShell.rightPanel.customize.moveTabUp": "Tab {label} nach oben verschieben", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index f3371780b..bd4fb6990 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Files", "instanceShell.rightPanel.tabs.status": "Status", "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", + "instanceShell.rightPanel.modules.core": "CodeNomad core", + "instanceShell.rightPanel.modules.core.description": "Built-in Git, Files, and Status panels.", "instanceShell.rightPanel.customize.toggle": "Customize right panel", "instanceShell.rightPanel.customize.title": "Customize right panel", - "instanceShell.rightPanel.customize.description": "Show or hide tabs and status sections.", + "instanceShell.rightPanel.customize.description": "Show or hide right panel modules.", "instanceShell.rightPanel.customize.reset": "Reset", "instanceShell.rightPanel.customize.tabs": "Tabs", + "instanceShell.rightPanel.customize.sections": "Customize sections", "instanceShell.rightPanel.customize.statusSections": "Status sections", - "instanceShell.rightPanel.customize.dragToReorder": "Drag tabs or status sections to reorder.", + "instanceShell.rightPanel.customize.dragToReorder": "Drag to reorder.", + "instanceShell.rightPanel.customize.alwaysVisible": "Always visible", + "instanceShell.rightPanel.customize.unavailableModules": "Unavailable modules", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} unavailable", "instanceShell.rightPanel.customize.moveUp": "Up", "instanceShell.rightPanel.customize.moveDown": "Down", "instanceShell.rightPanel.customize.moveTabUp": "Move {label} tab up", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index a8237d414..e24672a82 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", + "instanceShell.rightPanel.modules.core": "Núcleo de CodeNomad", + "instanceShell.rightPanel.modules.core.description": "Paneles integrados de Git, Archivos y Estado.", "instanceShell.rightPanel.customize.toggle": "Personalizar panel derecho", "instanceShell.rightPanel.customize.title": "Personalizar panel derecho", - "instanceShell.rightPanel.customize.description": "Muestra u oculta pestañas y secciones de estado.", + "instanceShell.rightPanel.customize.description": "Muestra u oculta módulos del panel derecho.", "instanceShell.rightPanel.customize.reset": "Restablecer", "instanceShell.rightPanel.customize.tabs": "Pestañas", + "instanceShell.rightPanel.customize.sections": "Personalizar secciones", "instanceShell.rightPanel.customize.statusSections": "Secciones de estado", - "instanceShell.rightPanel.customize.dragToReorder": "Arrastra pestañas o secciones de estado para reordenarlas.", + "instanceShell.rightPanel.customize.dragToReorder": "Arrastra para reordenar.", + "instanceShell.rightPanel.customize.alwaysVisible": "Siempre visible", + "instanceShell.rightPanel.customize.unavailableModules": "Módulos no disponibles", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} no disponible", "instanceShell.rightPanel.customize.moveUp": "Subir", "instanceShell.rightPanel.customize.moveDown": "Bajar", "instanceShell.rightPanel.customize.moveTabUp": "Subir pestaña {label}", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 87b84b634..51b865dc0 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Fichiers", "instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", + "instanceShell.rightPanel.modules.core": "Noyau CodeNomad", + "instanceShell.rightPanel.modules.core.description": "Panneaux Git, Fichiers et Statut intégrés.", "instanceShell.rightPanel.customize.toggle": "Personnaliser le panneau droit", "instanceShell.rightPanel.customize.title": "Personnaliser le panneau droit", - "instanceShell.rightPanel.customize.description": "Afficher ou masquer les onglets et sections de statut.", + "instanceShell.rightPanel.customize.description": "Afficher ou masquer les modules du panneau droit.", "instanceShell.rightPanel.customize.reset": "Réinitialiser", "instanceShell.rightPanel.customize.tabs": "Onglets", + "instanceShell.rightPanel.customize.sections": "Personnaliser les sections", "instanceShell.rightPanel.customize.statusSections": "Sections de statut", - "instanceShell.rightPanel.customize.dragToReorder": "Faites glisser les onglets ou sections de statut pour les réordonner.", + "instanceShell.rightPanel.customize.dragToReorder": "Faites glisser pour réordonner.", + "instanceShell.rightPanel.customize.alwaysVisible": "Toujours visible", + "instanceShell.rightPanel.customize.unavailableModules": "Modules indisponibles", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} indisponible", "instanceShell.rightPanel.customize.moveUp": "Monter", "instanceShell.rightPanel.customize.moveDown": "Descendre", "instanceShell.rightPanel.customize.moveTabUp": "Monter l'onglet {label}", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index 4d5a4af43..3e42ff082 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "קבצים", "instanceShell.rightPanel.tabs.status": "סטטוס", "instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני", + "instanceShell.rightPanel.modules.core": "ליבת CodeNomad", + "instanceShell.rightPanel.modules.core.description": "לוחות Git, קבצים וסטטוס מובנים.", "instanceShell.rightPanel.customize.toggle": "התאמת הלוח הימני", "instanceShell.rightPanel.customize.title": "התאמת הלוח הימני", - "instanceShell.rightPanel.customize.description": "הצגה או הסתרה של כרטיסיות ומקטעי סטטוס.", + "instanceShell.rightPanel.customize.description": "הצגה או הסתרה של מודולי הלוח הימני.", "instanceShell.rightPanel.customize.reset": "איפוס", "instanceShell.rightPanel.customize.tabs": "כרטיסיות", + "instanceShell.rightPanel.customize.sections": "התאמת מקטעים", "instanceShell.rightPanel.customize.statusSections": "מקטעי סטטוס", - "instanceShell.rightPanel.customize.dragToReorder": "גררו כרטיסיות או מקטעי סטטוס כדי לסדר מחדש.", + "instanceShell.rightPanel.customize.dragToReorder": "גררו כדי לסדר מחדש.", + "instanceShell.rightPanel.customize.alwaysVisible": "תמיד גלוי", + "instanceShell.rightPanel.customize.unavailableModules": "מודולים לא זמינים", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} לא זמין", "instanceShell.rightPanel.customize.moveUp": "למעלה", "instanceShell.rightPanel.customize.moveDown": "למטה", "instanceShell.rightPanel.customize.moveTabUp": "העברת הכרטיסייה {label} למעלה", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index b4eb22255..51952de54 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "ファイル", "instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", + "instanceShell.rightPanel.modules.core": "CodeNomad コア", + "instanceShell.rightPanel.modules.core.description": "組み込みの Git、ファイル、ステータスパネル。", "instanceShell.rightPanel.customize.toggle": "右パネルをカスタマイズ", "instanceShell.rightPanel.customize.title": "右パネルをカスタマイズ", - "instanceShell.rightPanel.customize.description": "タブとステータスセクションの表示/非表示を切り替えます。", + "instanceShell.rightPanel.customize.description": "右パネルのモジュールの表示/非表示を切り替えます。", "instanceShell.rightPanel.customize.reset": "リセット", "instanceShell.rightPanel.customize.tabs": "タブ", + "instanceShell.rightPanel.customize.sections": "セクションをカスタマイズ", "instanceShell.rightPanel.customize.statusSections": "ステータスセクション", - "instanceShell.rightPanel.customize.dragToReorder": "タブまたはステータスセクションをドラッグして並べ替えます。", + "instanceShell.rightPanel.customize.dragToReorder": "ドラッグして並べ替えます。", + "instanceShell.rightPanel.customize.alwaysVisible": "常に表示", + "instanceShell.rightPanel.customize.unavailableModules": "利用できないモジュール", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} は利用できません", "instanceShell.rightPanel.customize.moveUp": "上へ", "instanceShell.rightPanel.customize.moveDown": "下へ", "instanceShell.rightPanel.customize.moveTabUp": "{label} タブを上へ移動", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index 7f5e938a4..5bb3f3e0a 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "फाइलहरू", "instanceShell.rightPanel.tabs.status": "स्थिति", "instanceShell.rightPanel.tabs.ariaLabel": "दायाँ प्यानल ट्याबहरू", + "instanceShell.rightPanel.modules.core": "CodeNomad कोर", + "instanceShell.rightPanel.modules.core.description": "निर्मित Git, फाइलहरू, र स्थिति प्यानलहरू।", "instanceShell.rightPanel.customize.toggle": "दायाँ प्यानल अनुकूलन गर्नुहोस्", "instanceShell.rightPanel.customize.title": "दायाँ प्यानल अनुकूलन गर्नुहोस्", - "instanceShell.rightPanel.customize.description": "ट्याबहरू र स्थिति खण्डहरू देखाउनुहोस् वा लुकाउनुहोस्।", + "instanceShell.rightPanel.customize.description": "दायाँ प्यानल मोड्युलहरू देखाउनुहोस् वा लुकाउनुहोस्।", "instanceShell.rightPanel.customize.reset": "रिसेट गर्नुहोस्", "instanceShell.rightPanel.customize.tabs": "ट्याबहरू", + "instanceShell.rightPanel.customize.sections": "खण्डहरू अनुकूलन गर्नुहोस्", "instanceShell.rightPanel.customize.statusSections": "स्थिति खण्डहरू", - "instanceShell.rightPanel.customize.dragToReorder": "पुनःक्रमबद्ध गर्न ट्याब वा स्थिति खण्डहरू तान्नुहोस्।", + "instanceShell.rightPanel.customize.dragToReorder": "पुनःक्रमबद्ध गर्न तान्नुहोस्।", + "instanceShell.rightPanel.customize.alwaysVisible": "सधैं देखिने", + "instanceShell.rightPanel.customize.unavailableModules": "उपलब्ध नभएका मोड्युलहरू", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} उपलब्ध छैन", "instanceShell.rightPanel.customize.moveUp": "माथि", "instanceShell.rightPanel.customize.moveDown": "तल", "instanceShell.rightPanel.customize.moveTabUp": "{label} ट्याब माथि सार्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index 44386c157..80437e052 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Файлы", "instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", + "instanceShell.rightPanel.modules.core": "Ядро CodeNomad", + "instanceShell.rightPanel.modules.core.description": "Встроенные панели Git, Файлы и Статус.", "instanceShell.rightPanel.customize.toggle": "Настроить правую панель", "instanceShell.rightPanel.customize.title": "Настроить правую панель", - "instanceShell.rightPanel.customize.description": "Показывайте или скрывайте вкладки и секции статуса.", + "instanceShell.rightPanel.customize.description": "Показывайте или скрывайте модули правой панели.", "instanceShell.rightPanel.customize.reset": "Сбросить", "instanceShell.rightPanel.customize.tabs": "Вкладки", + "instanceShell.rightPanel.customize.sections": "Настроить секции", "instanceShell.rightPanel.customize.statusSections": "Секции статуса", - "instanceShell.rightPanel.customize.dragToReorder": "Перетаскивайте вкладки или секции статуса, чтобы изменить порядок.", + "instanceShell.rightPanel.customize.dragToReorder": "Перетащите, чтобы изменить порядок.", + "instanceShell.rightPanel.customize.alwaysVisible": "Всегда видно", + "instanceShell.rightPanel.customize.unavailableModules": "Недоступные модули", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} недоступен", "instanceShell.rightPanel.customize.moveUp": "Вверх", "instanceShell.rightPanel.customize.moveDown": "Вниз", "instanceShell.rightPanel.customize.moveTabUp": "Переместить вкладку {label} вверх", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index f1bb74eb6..6a29f2029 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -93,13 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "文件", "instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", + "instanceShell.rightPanel.modules.core": "CodeNomad 核心", + "instanceShell.rightPanel.modules.core.description": "内置 Git、文件和状态面板。", "instanceShell.rightPanel.customize.toggle": "自定义右侧面板", "instanceShell.rightPanel.customize.title": "自定义右侧面板", - "instanceShell.rightPanel.customize.description": "显示或隐藏标签页和状态分区。", + "instanceShell.rightPanel.customize.description": "显示或隐藏右侧面板模块。", "instanceShell.rightPanel.customize.reset": "重置", "instanceShell.rightPanel.customize.tabs": "标签页", + "instanceShell.rightPanel.customize.sections": "自定义分区", "instanceShell.rightPanel.customize.statusSections": "状态分区", - "instanceShell.rightPanel.customize.dragToReorder": "拖动标签页或状态分区以重新排序。", + "instanceShell.rightPanel.customize.dragToReorder": "拖动以重新排序。", + "instanceShell.rightPanel.customize.alwaysVisible": "始终可见", + "instanceShell.rightPanel.customize.unavailableModules": "不可用模块", + "instanceShell.rightPanel.customize.moduleUnavailable": "{module} 不可用", "instanceShell.rightPanel.customize.moveUp": "上移", "instanceShell.rightPanel.customize.moveDown": "下移", "instanceShell.rightPanel.customize.moveTabUp": "上移 {label} 标签页", diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index 1d0bb460c..612bf22f4 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -790,16 +790,7 @@ border: 1px solid var(--border-base); background-color: var(--surface-secondary); box-shadow: var(--popover-shadow); - padding: 0.75rem; -} - -.right-panel-customization-header { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 0.75rem; + padding: 0.5rem; } .right-panel-customization-grid { @@ -808,20 +799,6 @@ gap: 0.5rem; } -.right-panel-customization-group { - min-width: 0; - border: 1px solid var(--border-base); - background-color: var(--surface-primary); - padding: 0.5rem; -} - -.right-panel-customization-group-title { - color: var(--text-secondary); - font-size: 0.75rem; - font-weight: 600; - margin-bottom: 0.35rem; -} - .right-panel-customization-row { display: flex; align-items: center; @@ -830,6 +807,10 @@ padding: 0.25rem 0; } +.right-panel-customization-row-indent { + padding-inline-start: 1.5rem; +} + .right-panel-customization-label { min-width: 0; display: flex;