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 5f6865fb1..64f195471 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -1,4 +1,5 @@ import { + For, Show, Suspense, createEffect, @@ -11,10 +12,19 @@ import { } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" +import { + DragDropProvider, + DragDropSensors, + SortableProvider, + closestCenter, + createSortable, + type DragEvent as SolidDndDragEvent, +} from "@thisbeyond/solid-dnd" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" +import { Settings2 } from "lucide-solid" import type { Instance } from "../../../../types/instance" import type { BackgroundProcess } from "../../../../../../server/src/api-types" @@ -35,7 +45,7 @@ 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 { readClientLayoutValue, writeClientLayoutValue } from "../../../../stores/client-state" import { useGlobalPointerDrag } from "../useGlobalPointerDrag" import { useGitChanges } from "./useGitChanges" import { @@ -53,12 +63,24 @@ import { 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 { + applyRightPanelItemCustomization, + collectRightPanelItems, + parseRightPanelCustomization, + setRightPanelItemHidden, + type RightPanelCustomization, + type RightPanelModule, + type RightPanelSectionModule, + type RightPanelTabModule, +} from "./registry" +import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) const LazyFilesTab = lazy(() => import("./tabs/FilesTab")) @@ -68,6 +90,32 @@ function RightPanelTabFallback() { return
} +interface SortableRightPanelTabProps { + tab: RightPanelTabModule + active: boolean + label: string + dragTitle: string + onSelect: () => void +} + +const SortableRightPanelTab: Component = (props) => { + const sortable = createSortable(props.tab.id) + return ( +
+ +
+ ) +} + interface RightPanelProps { t: (key: string, vars?: Record) => string @@ -98,8 +146,12 @@ interface RightPanelProps { const RightPanel: Component = (props) => { const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("git-changes")) - const defaultStatusSectionIds = ["provider-usage", "yolo-mode", "plan", "background-processes", "mcp", "lsp", "plugins"] + const defaultStatusSectionIds = CORE_STATUS_SECTION_ITEMS.map((section) => section.id) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(defaultStatusSectionIds) + const [rightPanelCustomizationOpen, setRightPanelCustomizationOpen] = createSignal(false) + const [rightPanelCustomization, setRightPanelCustomization] = createSignal( + parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), + ) const [browserPath, setBrowserPath] = createSignal(".") const [browserEntries, setBrowserEntries] = createSignal(null) @@ -648,77 +700,42 @@ const RightPanel: Component = (props) => { setRightPanelExpandedItems(values) } - const tabClass = (tab: RightPanelTab) => - `right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}` + const updateRightPanelCustomization = (updater: (current: RightPanelCustomization) => RightPanelCustomization) => { + const next = updater(rightPanelCustomization()) + setRightPanelCustomization(next) + writeClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, JSON.stringify(next)) + } - return ( -
-
-
-
- - - - - - - (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())} - > - {props.rightPinned() ? : } - - -
-
-
-
- - - -
+ const moveTab = (sourceId: string, targetId: string) => { + if (!sourceId || sourceId === targetId) return + const visibleIds = visibleRightPanelTabs().map((tab) => tab.id) + const sourceIndex = visibleIds.indexOf(sourceId) + const targetIndex = visibleIds.indexOf(targetId) + if (sourceIndex === -1 || targetIndex === -1) return + const nextVisibleIds = [...visibleIds] + const [moved] = nextVisibleIds.splice(sourceIndex, 1) + nextVisibleIds.splice(targetIndex, 0, moved) + const allIds = allRightPanelTabs().map((tab) => tab.id) + updateRightPanelCustomization((current) => ({ + ...current, + tabOrder: [...nextVisibleIds, ...allIds.filter((id) => !nextVisibleIds.includes(id))], + })) + } -
-
-
-
-
+ const handleTabDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => { + if (!droppable) return + moveTab(String(draggable.id), String(droppable.id)) + } -
- - }> + const rightPanelModules = createMemo(() => [ + { + id: "core-right-panel", + tabs: [ + { + id: "git-changes", + labelKey: "instanceShell.rightPanel.tabs.gitChanges", + order: 10, + render: () => ( = (props) => { onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} isPhoneLayout={props.isPhoneLayout} /> - - - - - }> + ), + }, + { + id: "files", + labelKey: "instanceShell.rightPanel.tabs.files", + order: 20, + render: () => ( = (props) => { onResizeTouchStart={handleSplitResizeTouchStart("files")} isPhoneLayout={props.isPhoneLayout} /> - - - - - }> + ), + }, + { + id: "status", + labelKey: "instanceShell.rightPanel.tabs.status", + order: 30, + render: () => ( = (props) => { onTerminateBackgroundProcess={props.onTerminateBackgroundProcess} expandedItems={rightPanelExpandedItems} onExpandedItemsChange={handleAccordionChange} + customization={rightPanelCustomization} + onCustomizationChange={updateRightPanelCustomization} + extraSections={extraStatusSections()} /> - + ), + }, + ], + }, + ]) + + const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) + const visibleRightPanelTabs = createMemo(() => + applyRightPanelItemCustomization( + allRightPanelTabs(), + rightPanelCustomization().tabOrder, + rightPanelCustomization().hiddenTabIds, + ), + ) + const orderedRightPanelTabs = createMemo(() => applyRightPanelItemCustomization(allRightPanelTabs(), rightPanelCustomization().tabOrder, [])) + const extraStatusSections = createMemo(() => collectRightPanelItems(rightPanelModules(), "statusSections")) + const allStatusSections = createMemo(() => [...CORE_STATUS_SECTION_ITEMS, ...extraStatusSections()]) + const orderedStatusSections = createMemo(() => applyRightPanelItemCustomization(allStatusSections(), rightPanelCustomization().statusSectionOrder, [])) + const visibleStatusSections = createMemo(() => + applyRightPanelItemCustomization( + allStatusSections(), + rightPanelCustomization().statusSectionOrder, + rightPanelCustomization().hiddenStatusSectionIds, + ), + ) + const activeRightPanelTab = createMemo(() => visibleRightPanelTabs().find((tab) => tab.id === rightPanelTab()) ?? visibleRightPanelTabs()[0]) + + createEffect(() => { + const active = activeRightPanelTab() + if (active && active.id !== rightPanelTab()) { + setRightPanelTab(active.id) + } + }) + + return ( +
+
+
+
+ + + + + + + (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())} + > + {props.rightPinned() ? : } + + + setRightPanelCustomizationOpen((open) => !open)} + > + + +
+
+
+
+ + + tab.id)}> + + {(tab) => ( + setRightPanelTab(tab.id)} + /> + )} + + + + +
+ +
+
+
+
+
+ + + + + +
+ + {(tab) => }>{tab.render()}}
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 new file mode 100644 index 000000000..a7b39d015 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/registry.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + applyRightPanelItemCustomization, + collectRightPanelItems, + moveRightPanelItem, + parseRightPanelCustomization, + setRightPanelItemHidden, + type RightPanelItem, + 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 }) + +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)] }, + ], + "tabs", + ) + + assert.deepEqual(items.map((entry) => entry.id), ["changes", "custom", "status"]) + }) + + it("rejects duplicate item ids", () => { + assert.throws(() => collectRightPanelItems([{ id: "core", tabs: [tab("status", 10), tab("status", 20)] }], "tabs")) + }) + + it("applies visibility and user order", () => { + const visible = applyRightPanelItemCustomization( + [item("changes", 10), item("files", 20), item("status", 30)], + ["status", "changes"], + ["files"], + ) + + assert.deepEqual(visible.map((entry) => entry.id), ["status", "changes"]) + }) + + it("keeps always-visible items visible", () => { + const visible = applyRightPanelItemCustomization([item("configure", 100, true)], [], ["configure"]) + + assert.deepEqual(visible.map((entry) => entry.id), ["configure"]) + }) + + it("moves items in normalized order", () => { + assert.deepEqual(moveRightPanelItem(["changes", "status", "files"], ["changes", "files", "status"], "status", -1), [ + "status", + "changes", + "files", + ]) + }) + + it("parses invalid customization safely", () => { + assert.deepEqual(parseRightPanelCustomization("{"), { + tabOrder: [], + hiddenTabIds: [], + statusSectionOrder: [], + hiddenStatusSectionIds: [], + }) + }) + + it("toggles hidden ids", () => { + assert.deepEqual(setRightPanelItemHidden(["files"], "status", true), ["files", "status"]) + assert.deepEqual(setRightPanelItemHidden(["files"], "files", false), []) + }) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.ts b/packages/ui/src/components/instance/shell/right-panel/registry.ts new file mode 100644 index 000000000..c24077eac --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/registry.ts @@ -0,0 +1,113 @@ +import type { JSX } from "solid-js" + +export interface RightPanelItem { + id: string + labelKey: string + order: number + alwaysVisible?: boolean +} + +export interface RightPanelTabModule extends RightPanelItem { + render: () => JSX.Element +} + +export interface RightPanelSectionModule extends RightPanelItem { + tooltipKey: string + defaultExpanded?: boolean + render: () => JSX.Element +} + +export interface RightPanelModule { + id: string + tabs?: readonly RightPanelTabModule[] + statusSections?: readonly RightPanelSectionModule[] +} + +export interface RightPanelCustomization { + tabOrder: string[] + hiddenTabIds: string[] + statusSectionOrder: string[] + hiddenStatusSectionIds: string[] +} + +export const DEFAULT_RIGHT_PANEL_CUSTOMIZATION: RightPanelCustomization = { + tabOrder: [], + hiddenTabIds: [], + statusSectionOrder: [], + hiddenStatusSectionIds: [], +} + +export function parseRightPanelCustomization(value: string | null): RightPanelCustomization { + if (!value) return { ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION } + try { + const parsed = JSON.parse(value) as Partial + return { + tabOrder: readStringArray(parsed.tabOrder), + hiddenTabIds: readStringArray(parsed.hiddenTabIds), + statusSectionOrder: readStringArray(parsed.statusSectionOrder), + hiddenStatusSectionIds: readStringArray(parsed.hiddenStatusSectionIds), + } + } catch { + return { ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION } + } +} + +export function collectRightPanelItems(modules: readonly RightPanelModule[], key: "tabs" | "statusSections"): T[] { + const items = modules.flatMap((module) => [...((module[key] ?? []) as unknown as readonly T[])]) + const seen = new Set() + for (const item of items) { + if (!item.id) throw new Error(`Right panel ${key} must define an id`) + if (seen.has(item.id)) throw new Error(`Duplicate right panel ${key} id: ${item.id}`) + seen.add(item.id) + } + return sortRightPanelItems(items, []) +} + +export function applyRightPanelItemCustomization( + items: readonly T[], + orderedIds: readonly string[], + hiddenIds: readonly string[], +): T[] { + const hidden = new Set(hiddenIds) + const visible = items.filter((item) => item.alwaysVisible || !hidden.has(item.id)) + return sortRightPanelItems(visible.length > 0 ? visible : items.slice(0, 1), orderedIds) +} + +export function moveRightPanelItem(orderedIds: readonly string[], allIds: readonly string[], id: string, direction: -1 | 1): string[] { + const order = normalizeOrder(orderedIds, allIds) + const index = order.indexOf(id) + const nextIndex = index + direction + if (index === -1 || nextIndex < 0 || nextIndex >= order.length) return order + const next = [...order] + ;[next[index], next[nextIndex]] = [next[nextIndex], next[index]] + return next +} + +export function setRightPanelItemHidden(hiddenIds: readonly string[], id: string, hidden: boolean): string[] { + const next = new Set(hiddenIds) + if (hidden) next.add(id) + else next.delete(id) + return [...next] +} + +function sortRightPanelItems(items: readonly T[], orderedIds: readonly string[]): T[] { + const rank = new Map(orderedIds.map((id, index) => [id, index])) + return [...items].sort((left, right) => { + const leftRank = rank.get(left.id) + const rightRank = rank.get(right.id) + if (leftRank !== undefined || rightRank !== undefined) { + return (leftRank ?? Number.MAX_SAFE_INTEGER) - (rightRank ?? Number.MAX_SAFE_INTEGER) + } + return left.order - right.order || left.id.localeCompare(right.id) + }) +} + +function normalizeOrder(orderedIds: readonly string[], allIds: readonly string[]): string[] { + const known = new Set(allIds) + const ordered = orderedIds.filter((id, index) => known.has(id) && orderedIds.indexOf(id) === index) + return [...ordered, ...allIds.filter((id) => !ordered.includes(id))] +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [] +} 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 8bdc246a7..84baefd15 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 @@ -1,10 +1,18 @@ -import { For, Show, type Accessor, type Component } from "solid-js" +import { For, Show, createMemo, type Accessor, type Component } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" +import { + DragDropProvider, + DragDropSensors, + SortableProvider, + closestCenter, + createSortable, + type DragEvent as SolidDndDragEvent, +} from "@thisbeyond/solid-dnd" import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" +import { BellRing, ChevronDown, GripVertical, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { BackgroundProcess } from "../../../../../../../server/src/api-types" @@ -16,6 +24,7 @@ import { TodoListView } from "../../../../tool-call/renderers/todo" 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" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -35,7 +44,43 @@ interface StatusTabProps { expandedItems: Accessor onExpandedItemsChange: (values: string[]) => void + customization: Accessor + onCustomizationChange: (updater: (current: RightPanelCustomization) => RightPanelCustomization) => void + extraSections?: readonly RightPanelSectionModule[] +} + +interface SortableStatusSectionProps { + section: RightPanelSectionModule + expanded: boolean + t: (key: string, vars?: Record) => string +} +const SortableStatusSection: Component = (props) => { + const sortable = createSortable(props.section.id) + return ( +
+ + + + + + + + + + + + + + + {props.section.render()} + +
+ ) } const StatusTab: Component = (props) => { @@ -182,71 +227,83 @@ const StatusTab: Component = (props) => { ) } - const statusSections = [ - { - id: "yolo-mode", - labelKey: "instanceShell.rightPanel.sections.yoloMode", - tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip", - render: renderYoloModeSection, - }, - { - id: "provider-usage", - labelKey: "providerUsage.title", - tooltipKey: "providerUsage.tooltip", - render: renderProviderUsage, - }, - { - id: "plan", - labelKey: "instanceShell.rightPanel.sections.plan", - tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", - render: renderPlanSectionContent, - }, - { - id: "background-processes", - labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", - tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", - render: renderBackgroundProcesses, - }, - { - id: "mcp", - labelKey: "instanceShell.rightPanel.sections.mcp", - tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip", - render: () => ( - - ), - }, - { - id: "lsp", - labelKey: "instanceShell.rightPanel.sections.lsp", - tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip", - render: () => ( - - ), - }, - { - id: "plugins", - labelKey: "instanceShell.rightPanel.sections.plugins", - tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip", - render: () => ( - - ), - }, - ] + 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 ?? [])], + props.customization().statusSectionOrder, + props.customization().hiddenStatusSectionIds, + ) + }) + + const moveSection = (sourceId: string, targetId: string) => { + if (!sourceId || sourceId === targetId) return + const ids = statusSections().map((section) => section.id) + const sourceIndex = ids.indexOf(sourceId) + const targetIndex = ids.indexOf(targetId) + if (sourceIndex === -1 || targetIndex === -1) return + const next = [...ids] + const [moved] = next.splice(sourceIndex, 1) + next.splice(targetIndex, 0, moved) + props.onCustomizationChange((current) => ({ ...current, statusSectionOrder: next })) + } + + const handleSectionDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => { + if (!droppable) return + moveSection(String(draggable.id), String(droppable.id)) + } return (
@@ -263,31 +320,15 @@ const StatusTab: Component = (props) => { value={props.expandedItems()} onChange={props.onExpandedItemsChange} > - - {(section) => ( - - - - - - - - - - - - - - - - {section.render()} - - )} - + + + section.id)}> + + {(section) => } + + + +
) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/status-sections.ts b/packages/ui/src/components/instance/shell/right-panel/tabs/status-sections.ts new file mode 100644 index 000000000..87933d77f --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/status-sections.ts @@ -0,0 +1,46 @@ +import type { RightPanelItem } from "../registry" + +export const CORE_STATUS_SECTION_ITEMS: readonly (RightPanelItem & { tooltipKey: string; defaultExpanded?: boolean })[] = [ + { + id: "yolo-mode", + labelKey: "instanceShell.rightPanel.sections.yoloMode", + tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip", + order: 10, + }, + { + id: "provider-usage", + labelKey: "providerUsage.title", + tooltipKey: "providerUsage.tooltip", + order: 20, + }, + { + id: "plan", + labelKey: "instanceShell.rightPanel.sections.plan", + tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", + order: 30, + }, + { + id: "background-processes", + labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", + tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", + order: 40, + }, + { + id: "mcp", + labelKey: "instanceShell.rightPanel.sections.mcp", + tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip", + order: 50, + }, + { + id: "lsp", + labelKey: "instanceShell.rightPanel.sections.lsp", + tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip", + order: 60, + }, + { + id: "plugins", + labelKey: "instanceShell.rightPanel.sections.plugins", + tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip", + order: 70, + }, +] diff --git a/packages/ui/src/components/instance/shell/right-panel/types.ts b/packages/ui/src/components/instance/shell/right-panel/types.ts index aa407b6e0..e24e3b46a 100644 --- a/packages/ui/src/components/instance/shell/right-panel/types.ts +++ b/packages/ui/src/components/instance/shell/right-panel/types.ts @@ -1,4 +1,4 @@ -export type RightPanelTab = "git-changes" | "files" | "status" +export type RightPanelTab = string export type DiffViewMode = "split" | "unified" @@ -27,7 +27,7 @@ export type GitChangeSection = "staged" | "unstaged" export interface GitChangeListItem { id: string path: string - originalPath?: string | null + originalPath?: string | null section: GitChangeSection status: GitChangeStatus additions: number diff --git a/packages/ui/src/components/instance/shell/storage.ts b/packages/ui/src/components/instance/shell/storage.ts index 42bf0deed..0e685d4e9 100644 --- a/packages/ui/src/components/instance/shell/storage.ts +++ b/packages/ui/src/components/instance/shell/storage.ts @@ -16,6 +16,7 @@ export const LEFT_OPEN_STORAGE_KEY = "opencode-session-left-drawer-open-v1" export const RIGHT_OPEN_STORAGE_KEY = "opencode-session-right-drawer-open-v1" export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2" export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" +export const RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY = "opencode-session-right-panel-customization-v1" export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1" export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1" export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1" @@ -65,14 +66,13 @@ export function persistOpenState(side: "left" | "right", value: boolean) { writeClientLayoutValue(getOpenStorageKey(side), value ? "true" : "false") } -export function readStoredRightPanelTab( - defaultValue: "git-changes" | "files" | "status", -): "git-changes" | "files" | "status" { +export function readStoredRightPanelTab(defaultValue: string): string { const stored = readClientLayoutValue(RIGHT_PANEL_TAB_STORAGE_KEY) if (stored === "status") return "status" if (stored === "changes") return "git-changes" if (stored === "git-changes") return "git-changes" if (stored === "files") return "files" + if (stored) return stored // Migrate from v1 (where the stored values were the internal tab ids). const legacy = readClientLayoutValue(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY) diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index 5e8be37ba..5b5e9018c 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -93,6 +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.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.reset": "Zurücksetzen", + "instanceShell.rightPanel.customize.tabs": "Tabs", + "instanceShell.rightPanel.customize.statusSections": "Statusabschnitte", + "instanceShell.rightPanel.customize.dragToReorder": "Tabs oder Statusabschnitte ziehen, um sie neu anzuordnen.", + "instanceShell.rightPanel.customize.moveUp": "Nach oben", + "instanceShell.rightPanel.customize.moveDown": "Nach unten", + "instanceShell.rightPanel.customize.moveTabUp": "Tab {label} nach oben verschieben", + "instanceShell.rightPanel.customize.moveTabDown": "Tab {label} nach unten verschieben", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "Statusabschnitt {label} nach oben verschieben", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "Statusabschnitt {label} nach unten verschieben", "instanceShell.rightPanel.actions.refresh": "Aktualisieren", "instanceShell.rightPanel.actions.save": "Speichern (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "Möchten Sie die Änderungen an \"{path}\" speichern, bevor Sie wechseln?", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index d5f6425b5..f3371780b 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -93,6 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Files", "instanceShell.rightPanel.tabs.status": "Status", "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", + "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.reset": "Reset", + "instanceShell.rightPanel.customize.tabs": "Tabs", + "instanceShell.rightPanel.customize.statusSections": "Status sections", + "instanceShell.rightPanel.customize.dragToReorder": "Drag tabs or status sections to reorder.", + "instanceShell.rightPanel.customize.moveUp": "Up", + "instanceShell.rightPanel.customize.moveDown": "Down", + "instanceShell.rightPanel.customize.moveTabUp": "Move {label} tab up", + "instanceShell.rightPanel.customize.moveTabDown": "Move {label} tab down", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "Move {label} status section up", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "Move {label} status section down", "instanceShell.rightPanel.actions.refresh": "Refresh", "instanceShell.rightPanel.actions.save": "Save (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "Do you want to save changes to \"{path}\" before switching?", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index 7b5627934..a8237d414 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -93,6 +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.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.reset": "Restablecer", + "instanceShell.rightPanel.customize.tabs": "Pestañas", + "instanceShell.rightPanel.customize.statusSections": "Secciones de estado", + "instanceShell.rightPanel.customize.dragToReorder": "Arrastra pestañas o secciones de estado para reordenarlas.", + "instanceShell.rightPanel.customize.moveUp": "Subir", + "instanceShell.rightPanel.customize.moveDown": "Bajar", + "instanceShell.rightPanel.customize.moveTabUp": "Subir pestaña {label}", + "instanceShell.rightPanel.customize.moveTabDown": "Bajar pestaña {label}", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "Subir sección de estado {label}", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "Bajar sección de estado {label}", "instanceShell.rightPanel.actions.refresh": "Actualizar", "instanceShell.rightPanel.actions.save": "Guardar (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "¿Deseas guardar los cambios en \"{path}\" antes de cambiar?", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index d1ba7d3c9..87b84b634 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -93,6 +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.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.reset": "Réinitialiser", + "instanceShell.rightPanel.customize.tabs": "Onglets", + "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.moveUp": "Monter", + "instanceShell.rightPanel.customize.moveDown": "Descendre", + "instanceShell.rightPanel.customize.moveTabUp": "Monter l'onglet {label}", + "instanceShell.rightPanel.customize.moveTabDown": "Descendre l'onglet {label}", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "Monter la section de statut {label}", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "Descendre la section de statut {label}", "instanceShell.rightPanel.actions.refresh": "Actualiser", "instanceShell.rightPanel.actions.save": "Enregistrer (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "Voulez-vous enregistrer les modifications de \"{path}\" avant de changer ?", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index 5735fca56..4d5a4af43 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -93,6 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "קבצים", "instanceShell.rightPanel.tabs.status": "סטטוס", "instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני", + "instanceShell.rightPanel.customize.toggle": "התאמת הלוח הימני", + "instanceShell.rightPanel.customize.title": "התאמת הלוח הימני", + "instanceShell.rightPanel.customize.description": "הצגה או הסתרה של כרטיסיות ומקטעי סטטוס.", + "instanceShell.rightPanel.customize.reset": "איפוס", + "instanceShell.rightPanel.customize.tabs": "כרטיסיות", + "instanceShell.rightPanel.customize.statusSections": "מקטעי סטטוס", + "instanceShell.rightPanel.customize.dragToReorder": "גררו כרטיסיות או מקטעי סטטוס כדי לסדר מחדש.", + "instanceShell.rightPanel.customize.moveUp": "למעלה", + "instanceShell.rightPanel.customize.moveDown": "למטה", + "instanceShell.rightPanel.customize.moveTabUp": "העברת הכרטיסייה {label} למעלה", + "instanceShell.rightPanel.customize.moveTabDown": "העברת הכרטיסייה {label} למטה", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "העברת מקטע הסטטוס {label} למעלה", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "העברת מקטע הסטטוס {label} למטה", "instanceShell.rightPanel.actions.refresh": "רענן", "instanceShell.rightPanel.actions.save": "שמור (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "האם ברצונך לשמור את השינויים ב-\"{path}\" לפני המעבר?", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index e2f3e18e1..b4eb22255 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -93,6 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "ファイル", "instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", + "instanceShell.rightPanel.customize.toggle": "右パネルをカスタマイズ", + "instanceShell.rightPanel.customize.title": "右パネルをカスタマイズ", + "instanceShell.rightPanel.customize.description": "タブとステータスセクションの表示/非表示を切り替えます。", + "instanceShell.rightPanel.customize.reset": "リセット", + "instanceShell.rightPanel.customize.tabs": "タブ", + "instanceShell.rightPanel.customize.statusSections": "ステータスセクション", + "instanceShell.rightPanel.customize.dragToReorder": "タブまたはステータスセクションをドラッグして並べ替えます。", + "instanceShell.rightPanel.customize.moveUp": "上へ", + "instanceShell.rightPanel.customize.moveDown": "下へ", + "instanceShell.rightPanel.customize.moveTabUp": "{label} タブを上へ移動", + "instanceShell.rightPanel.customize.moveTabDown": "{label} タブを下へ移動", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "{label} ステータスセクションを上へ移動", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "{label} ステータスセクションを下へ移動", "instanceShell.rightPanel.actions.refresh": "更新", "instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "「{path}」への変更を切り替え前に保存しますか?", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index 2de2157ef..7f5e938a4 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -93,6 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "फाइलहरू", "instanceShell.rightPanel.tabs.status": "स्थिति", "instanceShell.rightPanel.tabs.ariaLabel": "दायाँ प्यानल ट्याबहरू", + "instanceShell.rightPanel.customize.toggle": "दायाँ प्यानल अनुकूलन गर्नुहोस्", + "instanceShell.rightPanel.customize.title": "दायाँ प्यानल अनुकूलन गर्नुहोस्", + "instanceShell.rightPanel.customize.description": "ट्याबहरू र स्थिति खण्डहरू देखाउनुहोस् वा लुकाउनुहोस्।", + "instanceShell.rightPanel.customize.reset": "रिसेट गर्नुहोस्", + "instanceShell.rightPanel.customize.tabs": "ट्याबहरू", + "instanceShell.rightPanel.customize.statusSections": "स्थिति खण्डहरू", + "instanceShell.rightPanel.customize.dragToReorder": "पुनःक्रमबद्ध गर्न ट्याब वा स्थिति खण्डहरू तान्नुहोस्।", + "instanceShell.rightPanel.customize.moveUp": "माथि", + "instanceShell.rightPanel.customize.moveDown": "तल", + "instanceShell.rightPanel.customize.moveTabUp": "{label} ट्याब माथि सार्नुहोस्", + "instanceShell.rightPanel.customize.moveTabDown": "{label} ट्याब तल सार्नुहोस्", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "{label} स्थिति खण्ड माथि सार्नुहोस्", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "{label} स्थिति खण्ड तल सार्नुहोस्", "instanceShell.rightPanel.actions.refresh": "रिफ्रेस", "instanceShell.rightPanel.actions.save": "बचत (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "परिवर्तन गर्नु अघि \"{path}\" मा गरिएका परिवर्तनहरू बचत गर्न चाहनुहुन्छ?", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index a48d505b7..44386c157 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -93,6 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Файлы", "instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", + "instanceShell.rightPanel.customize.toggle": "Настроить правую панель", + "instanceShell.rightPanel.customize.title": "Настроить правую панель", + "instanceShell.rightPanel.customize.description": "Показывайте или скрывайте вкладки и секции статуса.", + "instanceShell.rightPanel.customize.reset": "Сбросить", + "instanceShell.rightPanel.customize.tabs": "Вкладки", + "instanceShell.rightPanel.customize.statusSections": "Секции статуса", + "instanceShell.rightPanel.customize.dragToReorder": "Перетаскивайте вкладки или секции статуса, чтобы изменить порядок.", + "instanceShell.rightPanel.customize.moveUp": "Вверх", + "instanceShell.rightPanel.customize.moveDown": "Вниз", + "instanceShell.rightPanel.customize.moveTabUp": "Переместить вкладку {label} вверх", + "instanceShell.rightPanel.customize.moveTabDown": "Переместить вкладку {label} вниз", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "Переместить секцию статуса {label} вверх", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "Переместить секцию статуса {label} вниз", "instanceShell.rightPanel.actions.refresh": "Обновить", "instanceShell.rightPanel.actions.save": "Сохранить (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "Сохранить изменения в \"{path}\" перед переключением?", 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 640a40a2c..f1bb74eb6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -93,6 +93,19 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "文件", "instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", + "instanceShell.rightPanel.customize.toggle": "自定义右侧面板", + "instanceShell.rightPanel.customize.title": "自定义右侧面板", + "instanceShell.rightPanel.customize.description": "显示或隐藏标签页和状态分区。", + "instanceShell.rightPanel.customize.reset": "重置", + "instanceShell.rightPanel.customize.tabs": "标签页", + "instanceShell.rightPanel.customize.statusSections": "状态分区", + "instanceShell.rightPanel.customize.dragToReorder": "拖动标签页或状态分区以重新排序。", + "instanceShell.rightPanel.customize.moveUp": "上移", + "instanceShell.rightPanel.customize.moveDown": "下移", + "instanceShell.rightPanel.customize.moveTabUp": "上移 {label} 标签页", + "instanceShell.rightPanel.customize.moveTabDown": "下移 {label} 标签页", + "instanceShell.rightPanel.customize.moveStatusSectionUp": "上移 {label} 状态分区", + "instanceShell.rightPanel.customize.moveStatusSectionDown": "下移 {label} 状态分区", "instanceShell.rightPanel.actions.refresh": "刷新", "instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)", "instanceShell.rightPanel.actions.saveConfirm.message": "切换前是否保存对 \"{path}\" 的更改?", diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index 14bd0d36e..1d0bb460c 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -38,6 +38,7 @@ .right-panel-tab { @apply inline-flex items-center gap-2 px-4 py-2 text-xs font-medium transition-all duration-150 relative; font-family: var(--font-family-sans); + cursor: grab; outline: none; border: 1px solid transparent; border-bottom: none; @@ -46,6 +47,14 @@ z-index: 1; } +.right-panel-tab-draggable { + display: inline-flex; +} + +.right-panel-tab-draggable-active { + opacity: 0.75; +} + .right-panel-tab:focus-visible { @apply ring-2 ring-offset-1; ring-color: var(--accent-primary); @@ -768,6 +777,99 @@ min-height: 0; } +.right-panel-section-draggable-active { + opacity: 0.75; +} + +.right-panel-customization-popover { + position: absolute; + top: 2.75rem; + inset-inline-start: 0.5rem; + z-index: 20; + width: min(18rem, calc(100% - 1rem)); + 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; +} + +.right-panel-customization-grid { + display: grid; + grid-template-columns: 1fr; + 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; + justify-content: space-between; + gap: 0.5rem; + padding: 0.25rem 0; +} + +.right-panel-customization-label { + min-width: 0; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-primary); + font-size: 0.75rem; +} + +.right-panel-customization-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-base); + background-color: transparent; + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; + line-height: 1rem; + padding: 0.25rem 0.5rem; + transition: color 150ms, background-color 150ms; +} + +.right-panel-customization-button:hover:not(:disabled) { + color: var(--text-primary); + background-color: var(--surface-hover); +} + +.right-panel-customization-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color); +} + +.right-panel-customization-hint { + margin-top: 0.5rem; + color: var(--text-tertiary); + font-size: 0.6875rem; + line-height: 1rem; +} + /* Section info tooltip */ .section-info-trigger { @apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150;