feat(ui): improve right panel changes/files layout

This commit is contained in:
Shantur Rathore
2026-02-10 18:31:12 +00:00
parent d9d281af8c
commit 16c2eeca3e
2 changed files with 819 additions and 322 deletions

View File

@@ -104,13 +104,22 @@ const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2"
const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1"
const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1"
const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1"
const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1"
const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1"
const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
type LayoutMode = "desktop" | "tablet" | "phone" type LayoutMode = "desktop" | "tablet" | "phone"
type RightPanelTab = "files" | "browser" | "status" type RightPanelTab = "changes" | "files" | "status"
const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
const clampRightWidth = (value: number) => { const clampRightWidth = (value: number) => {
@@ -133,12 +142,44 @@ function persistPinState(side: "left" | "right", value: boolean) {
function readStoredRightPanelTab(defaultValue: RightPanelTab): RightPanelTab { function readStoredRightPanelTab(defaultValue: RightPanelTab): RightPanelTab {
if (typeof window === "undefined") return defaultValue if (typeof window === "undefined") return defaultValue
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY) const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
if (stored === "status") return "status" if (stored === "status") return "status"
if (stored === "browser") return "browser" if (stored === "changes") return "changes"
if (stored === "files") return "files"
// Migrate from v1 (where the stored values were the internal tab ids).
const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY)
if (legacy === "status") return "status"
if (legacy === "browser") return "files"
if (legacy === "files") return "changes"
return defaultValue return defaultValue
} }
function readStoredPanelWidth(key: string, fallback: number) {
if (typeof window === "undefined") return fallback
const stored = window.localStorage.getItem(key)
if (!stored) return fallback
const parsed = Number.parseInt(stored, 10)
return Number.isFinite(parsed) ? parsed : fallback
}
function readStoredBool(key: string): boolean | null {
if (typeof window === "undefined") return null
const stored = window.localStorage.getItem(key)
if (stored === "true") return true
if (stored === "false") return false
return null
}
function readStoredEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
if (typeof window === "undefined") return null
const stored = window.localStorage.getItem(key)
if (!stored) return null
return (allowed as readonly string[]).includes(stored) ? (stored as T) : null
}
const InstanceShell2: Component<InstanceShellProps> = (props) => { const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
@@ -162,7 +203,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartX, setResizeStartX] = createSignal(0)
const [resizeStartWidth, setResizeStartWidth] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("files")) const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([ const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"plan", "plan",
"background-processes", "background-processes",
@@ -181,8 +222,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null) const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<"split" | "unified">("split") const [diffViewMode, setDiffViewMode] = createSignal<"split" | "unified">(
const [diffContextMode, setDiffContextMode] = createSignal<"expanded" | "collapsed">("collapsed") readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
)
const [diffContextMode, setDiffContextMode] = createSignal<"expanded" | "collapsed">(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
const [activeSplitResize, setActiveSplitResize] = createSignal<"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 [changesListOpen, setChangesListOpen] = createSignal(true)
const [changesListTouched, setChangesListTouched] = createSignal(false)
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 [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
@@ -207,6 +273,45 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
const listLayoutKey = createMemo(() => (isPhoneLayout() ? "phone" : "nonphone"))
const listOpenStorageKey = (tab: "changes" | "files") => {
const layout = listLayoutKey()
if (tab === "changes") {
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
}
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
}
const persistListOpen = (tab: "changes" | "files", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(listOpenStorageKey(tab), 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 changesPersisted = readStoredBool(listOpenStorageKey("changes"))
if (changesPersisted !== null) {
setChangesListOpen(changesPersisted)
setChangesListTouched(true)
} else {
setChangesListOpen(true)
setChangesListTouched(false)
}
})
const persistPinIfSupported = (side: "left" | "right", value: boolean) => { const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
if (side === "left" && !leftPinningSupported()) return if (side === "left" && !leftPinningSupported()) return
if (side === "right" && !rightPinningSupported()) return if (side === "right" && !rightPinningSupported()) return
@@ -281,6 +386,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
setRightDrawerWidth(clampRightWidth(window.innerWidth * 0.35)) setRightDrawerWidth(clampRightWidth(window.innerWidth * 0.35))
} }
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
const handleResize = () => { const handleResize = () => {
const width = clampWidth(window.innerWidth * 0.3) const width = clampWidth(window.innerWidth * 0.3)
setSessionSidebarWidth((current) => clampWidth(current || width)) setSessionSidebarWidth((current) => clampWidth(current || width))
@@ -320,6 +428,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
}) })
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
})
createEffect(() => { createEffect(() => {
props.tabBarOffset props.tabBarOffset
requestAnimationFrame(() => measureDrawerHost()) requestAnimationFrame(() => measureDrawerHost())
@@ -765,6 +883,95 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
stopDrawerResize() stopDrawerResize()
}) })
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(rightDrawerWidth() * 0.65))
const max = Math.min(560, maxByDrawer)
return Math.min(max, Math.max(min, Math.floor(value)))
}
const persistSplitWidth = (mode: "changes" | "files", width: number) => {
if (typeof window === "undefined") return
const key = mode === "changes" ? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
window.localStorage.setItem(key, String(width))
}
function stopSplitResize() {
setActiveSplitResize(null)
if (typeof document === "undefined") return
document.removeEventListener("mousemove", splitMouseMove)
document.removeEventListener("mouseup", splitMouseUp)
document.removeEventListener("touchmove", splitTouchMove)
document.removeEventListener("touchend", splitTouchEnd)
}
function splitMouseMove(event: MouseEvent) {
const mode = activeSplitResize()
if (!mode) return
event.preventDefault()
const delta = event.clientX - splitResizeStartX()
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitMouseUp() {
const mode = activeSplitResize()
if (mode) {
const width = mode === "changes" ? changesSplitWidth() : 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 delta = touch.clientX - splitResizeStartX()
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitTouchEnd() {
const mode = activeSplitResize()
if (mode) {
const width = mode === "changes" ? changesSplitWidth() : filesSplitWidth()
persistSplitWidth(mode, width)
}
stopSplitResize()
}
const startSplitResize = (mode: "changes" | "files", clientX: number) => {
if (typeof document === "undefined") return
setActiveSplitResize(mode)
setSplitResizeStartX(clientX)
setSplitResizeStartWidth(mode === "changes" ? changesSplitWidth() : filesSplitWidth())
document.addEventListener("mousemove", splitMouseMove)
document.addEventListener("mouseup", splitMouseUp)
document.addEventListener("touchmove", splitTouchMove, { passive: false })
document.addEventListener("touchend", splitTouchEnd)
}
const handleSplitResizeMouseDown = (mode: "changes" | "files") => (event: MouseEvent) => {
event.preventDefault()
startSplitResize(mode, event.clientX)
}
const handleSplitResizeTouchStart = (mode: "changes" | "files") => (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
startSplitResize(mode, touch.clientX)
}
onCleanup(() => {
stopSplitResize()
})
type DrawerViewState = "pinned" | "floating-open" | "floating-closed" type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
@@ -1035,6 +1242,36 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer())) const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer()))
const bestDiffFile = createMemo<string | null>(() => {
const diffs = activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null
const best = diffs.reduce((currentBest, item) => {
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
}, diffs[0])
return typeof (best as any)?.file === "string" ? (best as any).file : null
})
createEffect(() => {
const next = bestDiffFile()
if (!next) return
const diffs = activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return
const current = selectedFile()
if (current && diffs.some((d) => d.file === current)) return
setSelectedFile(next)
})
const normalizeBrowserPath = (input: string) => { const normalizeBrowserPath = (input: string) => {
const raw = String(input || ".").trim() const raw = String(input || ".").trim()
if (!raw || raw === "./") return "." if (!raw || raw === "./") return "."
@@ -1071,6 +1308,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
setBrowserSelectedLoading(true) setBrowserSelectedLoading(true)
setBrowserSelectedError(null) setBrowserSelectedError(null)
setBrowserSelectedContent(null) setBrowserSelectedContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (isPhoneLayout()) {
setFilesListOpen(false)
}
try { try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read") const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type const type = (content as any)?.type
@@ -1094,7 +1336,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
} }
createEffect(() => { createEffect(() => {
if (rightPanelTab() !== "browser") return if (rightPanelTab() !== "files") return
if (browserLoading()) return if (browserLoading()) return
if (browserEntries() !== null) return if (browserEntries() !== null) return
void loadBrowserEntries(browserPath()) void loadBrowserEntries(browserPath())
@@ -1137,9 +1379,23 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
// Select first file by default if none selected const mostChanged = sorted.reduce((best, item) => {
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del
if (score > bestScore) return item
if (score < bestScore) return best
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
}, sorted[0])
// Auto-select the most-changed file if none selected.
const currentSelected = selectedFile() const currentSelected = selectedFile()
const selectedFileData = sorted.find((f) => f.file === currentSelected) || sorted[0] const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
const scopeKey = `${props.instance.id}:${sessionId}` const scopeKey = `${props.instance.id}:${sessionId}`
@@ -1153,41 +1409,46 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return false return false
} }
if (isPhoneLayout()) {
return ( return (
<div class="files-tab-container"> <div class="files-tab-container">
<div class="rounded-lg border border-base bg-surface-secondary p-2 max-h-[32vh] overflow-y-auto"> <div class="files-tab-header">
<div class="flex flex-col"> <div class="files-tab-header-row">
<For each={sorted}>
{(item) => (
<button <button
type="button" type="button"
class={`border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm ${selectedFileData?.file === item.file ? "bg-surface-base" : ""}`} class="files-toggle-button"
onClick={() => setSelectedFile(item.file)} onClick={() => {
title={item.file} setChangesListTouched(true)
setChangesListOpen((current) => {
const next = !current
persistListOpen("changes", next)
return next
})
}}
> >
<div class="flex items-center justify-between gap-3"> {changesListOpen() ? "Hide files" : "Show files"}
<div
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
title={item.file}
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
>
{item.file}
</div>
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
</div>
</div>
</button> </button>
)}
</For> <span class="files-tab-selected-path" title={selectedFileData?.file || ""}>
{selectedFileData?.file || ""}
</span>
<div class="files-tab-stats" style={{ "flex": "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
</div>
</div> </div>
</div> </div>
<div class="files-tab-body">
<Show
when={!isPhoneLayout() && changesListOpen()}
fallback={
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-header"> <div class="file-viewer-header">
<span class="file-viewer-title">{t("instanceShell.filesShell.viewerTitle")}</span>
<div class="file-viewer-toolbar"> <div class="file-viewer-toolbar">
<button <button
type="button" type="button"
@@ -1256,41 +1517,21 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
</div> </div>
</div> </div>
</div>
)
} }
>
return ( <div class="files-split" style={{ "--files-pane-width": `${changesSplitWidth()}px` }}>
<div class="files-tab-container">
<div class="files-tab-header">
<div class="files-tab-stats">
<span class="files-tab-stat">
<span class="files-tab-stat-value">{sorted.length}</span>
<span>files</span>
</span>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
<span>additions</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
<span>deletions</span>
</span>
</div>
</div>
<div class="flex min-h-0 gap-3 flex-1">
<div class="file-list-panel"> <div class="file-list-panel">
<div class="file-list-header">
<span class="file-list-title">{t("instanceShell.filesShell.fileListTitle")}</span>
<span class="file-list-count">{sorted.length}</span>
</div>
<div class="file-list-scroll"> <div class="file-list-scroll">
<For each={sorted}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => setSelectedFile(item.file)} onClick={() => {
setSelectedFile(item.file)
if (isPhoneLayout()) {
setChangesListOpen(false)
}
}}
> >
<div class="file-list-item-content"> <div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}> <div class="file-list-item-path" title={item.file}>
@@ -1306,9 +1547,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</For> </For>
</div> </div>
</div> </div>
<div
class="file-split-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize file list"
onMouseDown={handleSplitResizeMouseDown("changes")}
onTouchStart={handleSplitResizeTouchStart("changes")}
/>
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-header"> <div class="file-viewer-header">
<span class="file-viewer-title">{t("instanceShell.filesShell.viewerTitle")}</span>
<div class="file-viewer-toolbar"> <div class="file-viewer-toolbar">
<button <button
type="button" type="button"
@@ -1378,6 +1626,56 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
</Show>
<Show when={isPhoneLayout()}>
<Show when={changesListOpen()}>
<div class="file-list-overlay" role="dialog" aria-label="Changes">
<div class="file-list-overlay-header">
<span class="files-tab-selected-path" title={selectedFileData?.file || ""}>
{selectedFileData?.file || ""}
</span>
<button
type="button"
class="files-toggle-button"
onClick={() => {
setChangesListTouched(true)
setChangesListOpen(false)
persistListOpen("changes", false)
}}
aria-label="Close files"
>
Close
</button>
</div>
<div class="file-list-scroll">
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => {
setSelectedFile(item.file)
setChangesListOpen(false)
}}
title={item.file}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}>
{item.file}
</div>
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</Show>
</div>
</div> </div>
) )
} }
@@ -1402,12 +1700,30 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const parent = getParentPath(browserPath()) const parent = getParentPath(browserPath())
const scopeKey = `${props.instance.id}:${worktreeSlugForViewer()}` const scopeKey = `${props.instance.id}:${worktreeSlugForViewer()}`
const toggleFilesList = () => {
setFilesListTouched(true)
setFilesListOpen((current) => {
const next = !current
persistListOpen("files", next)
return next
})
}
const headerDisplayedPath = () => browserSelectedPath() || browserPath()
return ( return (
<div class="files-tab-container"> <div class="files-tab-container">
<div class="files-tab-header"> <div class="files-tab-header">
<div class="files-tab-header-row">
<button type="button" class="files-toggle-button" onClick={toggleFilesList}>
{filesListOpen() ? "Hide files" : "Show files"}
</button>
<div class="files-tab-stats"> <div class="files-tab-stats">
<span class="files-tab-stat"> <span class="files-tab-stat">
<span class="files-tab-stat-value">{browserPath()}</span> <span class="files-tab-selected-path" title={headerDisplayedPath()}>
{headerDisplayedPath()}
</span>
</span> </span>
<Show when={browserLoading()}> <Show when={browserLoading()}>
<span>Loading</span> <span>Loading</span>
@@ -1417,57 +1733,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
</div> </div>
</div> </div>
<div class="flex min-h-0 gap-3 flex-1">
<div class="file-list-panel">
<div class="file-list-header">
<span class="file-list-title">Files</span>
<span class="file-list-count">{sorted.length}</span>
</div>
<div class="file-list-scroll">
<Show when={parent}>
{(p) => (
<div class="file-list-item" onClick={() => void loadBrowserEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
..
</div>
</div>
</div>
)}
</Show>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
void loadBrowserEntries(item.path)
return
}
void openBrowserFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
{item.name}
</div>
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div> </div>
<div class="files-tab-body">
<Show
when={!isPhoneLayout() && filesListOpen()}
fallback={
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<span class="file-viewer-title">Viewer</span>
</div>
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={browserSelectedLoading()} when={browserSelectedLoading()}
@@ -1509,6 +1781,164 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
</div> </div>
</div> </div>
}
>
<div class="files-split" style={{ "--files-pane-width": `${filesSplitWidth()}px` }}>
<div class="file-list-panel">
<div class="file-list-scroll">
<Show when={parent}>
{(p) => (
<div class="file-list-item" onClick={() => void loadBrowserEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
..
</div>
</div>
</div>
)}
</Show>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
void loadBrowserEntries(item.path)
return
}
void openBrowserFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
{item.name}
</div>
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
<div
class="file-split-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize file list"
onMouseDown={handleSplitResizeMouseDown("files")}
onTouchStart={handleSplitResizeTouchStart("files")}
/>
<div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={browserSelectedLoading()}
fallback={
<Show
when={browserSelectedError()}
fallback={
<Show
when={browserSelectedPath() && browserSelectedContent() !== null
? { path: browserSelectedPath() as string, content: browserSelectedContent() as string }
: null}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Select a file to preview</span>
</div>
}
>
{(payload) => (
<MonacoFileViewer
scopeKey={scopeKey}
path={payload().path}
content={payload().content}
/>
)}
</Show>
}
>
{(err) => (
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{err()}</span>
</div>
)}
</Show>
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
</div>
</Show>
</div>
</div>
</div>
</Show>
<Show when={isPhoneLayout()}>
<Show when={filesListOpen()}>
<div class="file-list-overlay" role="dialog" aria-label="Files">
<div class="file-list-overlay-header">
<span class="files-tab-selected-path" title={browserPath()}>
{browserPath()}
</span>
<button
type="button"
class="files-toggle-button"
onClick={() => {
setFilesListTouched(true)
setFilesListOpen(false)
persistListOpen("files", false)
}}
aria-label="Close files"
>
Close
</button>
</div>
<div class="file-list-scroll">
<Show when={parent}>
{(p) => (
<div class="file-list-item" onClick={() => void loadBrowserEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
..
</div>
</div>
</div>
)}
</Show>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
void loadBrowserEntries(item.path)
return
}
void openBrowserFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
{item.name}
</div>
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</Show>
</div> </div>
</div> </div>
) )
@@ -1555,7 +1985,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
if (file) { if (file) {
setSelectedFile(file) setSelectedFile(file)
} }
setRightPanelTab("files") setRightPanelTab("changes")
} }
return ( return (
@@ -1831,18 +2261,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<button <button
type="button" type="button"
role="tab" role="tab"
class={tabClass("files")} class={tabClass("changes")}
aria-selected={rightPanelTab() === "files"} aria-selected={rightPanelTab() === "changes"}
onClick={() => setRightPanelTab("files")} onClick={() => setRightPanelTab("changes")}
> >
<span class="tab-label">{t("instanceShell.rightPanel.tabs.changes")}</span> <span class="tab-label">{t("instanceShell.rightPanel.tabs.changes")}</span>
</button> </button>
<button <button
type="button" type="button"
role="tab" role="tab"
class={tabClass("browser")} class={tabClass("files")}
aria-selected={rightPanelTab() === "browser"} aria-selected={rightPanelTab() === "files"}
onClick={() => setRightPanelTab("browser")} onClick={() => setRightPanelTab("files")}
> >
<span class="tab-label">{t("instanceShell.rightPanel.tabs.files")}</span> <span class="tab-label">{t("instanceShell.rightPanel.tabs.files")}</span>
</button> </button>
@@ -1864,8 +2294,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div> </div>
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "files"}>{renderFilesTabContent()}</Show> <Show when={rightPanelTab() === "changes"}>{renderFilesTabContent()}</Show>
<Show when={rightPanelTab() === "browser"}>{renderBrowserTabContent()}</Show> <Show when={rightPanelTab() === "files"}>{renderBrowserTabContent()}</Show>
<Show when={rightPanelTab() === "status"}>{renderStatusTabContent()}</Show> <Show when={rightPanelTab() === "status"}>{renderStatusTabContent()}</Show>
</div> </div>
</div> </div>

View File

@@ -70,18 +70,87 @@
/* Files tab layout */ /* Files tab layout */
.files-tab-container { .files-tab-container {
@apply flex flex-col h-full min-h-0 p-3 gap-3; @apply flex flex-col h-full min-h-0;
}
/* Split view (file list + viewer) */
.files-split {
display: grid;
grid-template-columns: var(--files-pane-width, 320px) 10px minmax(0, 1fr);
min-height: 0;
flex: 1 1 auto;
}
.file-split-handle {
cursor: col-resize;
background-color: transparent;
border-left: 1px solid var(--border-base);
border-right: 1px solid var(--border-base);
user-select: none;
touch-action: none;
}
.file-split-handle:hover {
background-color: var(--surface-hover);
} }
.files-tab-header { .files-tab-header {
@apply flex items-center justify-between gap-2 px-1; @apply flex items-center justify-between gap-2 px-3 py-2 border-b;
border-color: var(--border-base);
} }
.files-tab-header-row {
@apply flex items-center gap-2 w-full min-w-0;
}
.files-toggle-button {
@apply text-[11px] px-2 py-1 border border-base transition-colors;
background-color: var(--surface-base);
color: var(--text-secondary);
}
.files-toggle-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.files-tab-body {
@apply flex flex-col flex-1 min-h-0;
position: relative;
}
.file-list-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background-color: var(--surface-secondary);
border-left: 1px solid var(--border-base);
z-index: 5;
}
.file-list-overlay-header {
@apply flex items-center justify-between gap-2 px-3 py-2 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
/* Overlay title intentionally unused; header shows current path instead. */
.files-tab-stats { .files-tab-stats {
@apply flex items-center gap-3 text-[11px]; @apply flex items-center gap-3 text-[11px];
color: var(--text-muted); color: var(--text-muted);
} }
.files-tab-selected-path {
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
color: var(--text-primary);
text-overflow: ellipsis;
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
.files-tab-stat { .files-tab-stat {
@apply flex items-center gap-1.5; @apply flex items-center gap-1.5;
} }
@@ -100,15 +169,14 @@
/* File list panel */ /* File list panel */
.file-list-panel { .file-list-panel {
@apply rounded-lg border flex flex-col min-h-0; @apply flex flex-col min-h-0;
background-color: var(--surface-secondary); background-color: transparent;
border-color: var(--border-base);
} }
.file-list-header { .file-list-header {
@apply flex items-center justify-between gap-2 px-3 py-2 border-b; @apply flex items-center justify-between gap-2 px-3 py-2 border-b;
border-color: var(--border-base); border-color: var(--border-base);
background-color: var(--surface-secondary); background-color: transparent;
} }
.file-list-title { .file-list-title {
@@ -173,15 +241,14 @@
/* File viewer panel */ /* File viewer panel */
.file-viewer-panel { .file-viewer-panel {
@apply rounded-lg border flex flex-col min-h-0; @apply flex flex-col min-h-0;
background-color: var(--surface-secondary); background-color: transparent;
border-color: var(--border-base);
} }
.file-viewer-header { .file-viewer-header {
@apply flex items-center gap-2 px-3 py-2 border-b; @apply flex items-center gap-2 px-3 py-2 border-b;
border-color: var(--border-base); border-color: var(--border-base);
background-color: var(--surface-secondary); background-color: transparent;
} }
.file-viewer-toolbar { .file-viewer-toolbar {