diff options
| author | Adam <[email protected]> | 2026-01-01 10:52:26 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-01 21:03:03 -0600 |
| commit | b8872d9d20c76ef351a0ec356558b1484a74f20f (patch) | |
| tree | dca28cfbb0f72e3d2507df06849b1647975daccd /packages/app | |
| parent | 78940d5b7ee2f3e5020f87b400db1785b37a7d71 (diff) | |
| download | opencode-b8872d9d20c76ef351a0ec356558b1484a74f20f.tar.gz opencode-b8872d9d20c76ef351a0ec356558b1484a74f20f.zip | |
wip(desktop): progress
Diffstat (limited to 'packages/app')
| -rw-r--r-- | packages/app/src/context/layout.tsx | 78 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 22 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 199 |
3 files changed, 244 insertions, 55 deletions
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 613a0e0c1..6a9258b4c 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -23,11 +23,28 @@ export function getAvatarColors(key?: string) { } } +function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + type SessionTabs = { active?: string all: string[] } +type SessionScroll = { + x: number + y: number +} + +type SessionView = { + scroll: Record<string, SessionScroll> + reviewOpen?: string[] +} + export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type ReviewDiffStyle = "unified" | "split" @@ -39,7 +56,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const globalSync = useGlobalSync() const server = useServer() const [store, setStore, _, ready] = persisted( - "layout.v4", + "layout.v6", createStore({ sidebar: { opened: false, @@ -56,7 +73,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( session: { width: 600, }, + mobileSidebar: { + opened: false, + }, sessionTabs: {} as Record<string, SessionTabs>, + sessionView: {} as Record<string, SessionView>, }), ) @@ -182,11 +203,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { if (!store.session) { setStore("session", { width }) - } else { - setStore("session", "width", width) + return } + setStore("session", "width", width) + }, + }, + mobileSidebar: { + opened: createMemo(() => store.mobileSidebar?.opened ?? false), + show() { + setStore("mobileSidebar", "opened", true) + }, + hide() { + setStore("mobileSidebar", "opened", false) + }, + toggle() { + setStore("mobileSidebar", "opened", (x) => !x) }, }, + view(sessionKey: string) { + const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) + return { + scroll(tab: string) { + return s().scroll?.[tab] + }, + setScroll(tab: string, pos: SessionScroll) { + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { scroll: { [tab]: pos } }) + return + } + + const prev = current.scroll?.[tab] + if (prev?.x === pos.x && prev?.y === pos.y) return + setStore("sessionView", sessionKey, "scroll", tab, pos) + }, + review: { + open: createMemo(() => s().reviewOpen), + setOpen(open: string[]) { + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open }) + return + } + + if (same(current.reviewOpen, open)) return + setStore("sessionView", sessionKey, "reviewOpen", open) + }, + }, + } + }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { @@ -256,11 +321,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (current.active !== tab) return const index = current.all.findIndex((f) => f === tab) - if (index <= 0) { - setStore("sessionTabs", sessionKey, "active", undefined) - return - } - setStore("sessionTabs", sessionKey, "active", current.all[index - 1]) + const next = all[index - 1] ?? all[0] + setStore("sessionTabs", sessionKey, "active", next) }) }, move(tab: string, to: number) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4629cd9b6..e237d2184 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -62,17 +62,9 @@ export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, activeDraggable: undefined as string | undefined, - mobileSidebarOpen: false, mobileProjectsExpanded: {} as Record<string, boolean>, }) - const mobileSidebar = { - open: () => store.mobileSidebarOpen, - show: () => setStore("mobileSidebarOpen", true), - hide: () => setStore("mobileSidebarOpen", false), - toggle: () => setStore("mobileSidebarOpen", (x) => !x), - } - const mobileProjects = { expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), @@ -468,13 +460,13 @@ export default function Layout(props: ParentProps) { if (!directory) return const lastSession = store.lastSession[directory] navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) - mobileSidebar.hide() + layout.mobileSidebar.hide() } function navigateToSession(session: Session | undefined) { if (!session) return navigate(`/${params.dir}/session/${session?.id}`) - mobileSidebar.hide() + layout.mobileSidebar.hide() } function openProject(directory: string, navigate = true) { @@ -1064,18 +1056,18 @@ export default function Layout(props: ParentProps) { <div classList={{ "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true, - "opacity-100 pointer-events-auto": mobileSidebar.open(), - "opacity-0 pointer-events-none": !mobileSidebar.open(), + "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(), + "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(), }} onClick={(e) => { - if (e.target === e.currentTarget) mobileSidebar.hide() + if (e.target === e.currentTarget) layout.mobileSidebar.hide() }} /> <div classList={{ "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true, - "translate-x-0": mobileSidebar.open(), - "-translate-x-full": !mobileSidebar.open(), + "translate-x-0": layout.mobileSidebar.opened(), + "-translate-x-full": !layout.mobileSidebar.opened(), }} onClick={(e) => e.stopPropagation()} > diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f0e6a6e1d..7f0203222 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -84,7 +84,7 @@ function same<T>(a: readonly T[], b: readonly T[]) { return a.every((x, i) => x === b[i]) } -function Header(props: { onMobileMenuToggle?: () => void }) { +function Header() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() @@ -113,7 +113,7 @@ function Header(props: { onMobileMenuToggle?: () => void }) { <button type="button" class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors" - onClick={props.onMobileMenuToggle} + onClick={layout.mobileSidebar.toggle} > <Icon name="menu" size="small" /> </button> @@ -291,6 +291,7 @@ export default function Page() { const permission = usePermission() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) + const view = createMemo(() => layout.view(sessionKey())) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -822,6 +823,8 @@ export default function Page() { .filter((tab) => tab !== "context"), ) + const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review") + const showTabs = createMemo( () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()), ) @@ -829,8 +832,19 @@ export default function Page() { const activeTab = createMemo(() => { const active = tabs().active() if (active) return active - if (diffs().length > 0) return "review" - return tabs().all()[0] ?? "review" + if (reviewTab()) return "review" + + const first = openedTabs()[0] + if (first) return first + if (contextOpen()) return "context" + return "review" + }) + + createEffect(() => { + if (!layout.ready()) return + if (tabs().active()) return + if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return + tabs().setActive(activeTab()) }) const mobileWorking = createMemo(() => status().type !== "idle") @@ -1209,8 +1223,63 @@ export default function Page() { ) } + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view()?.scroll("context") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll("context", next) + }) + } + + createEffect( + on( + () => messages().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + return ( - <div class="@container h-full overflow-y-auto no-scrollbar pb-10"> + <div + class="@container h-full overflow-y-auto no-scrollbar pb-10" + ref={(el) => { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > <div class="px-6 pt-4 flex flex-col gap-10"> <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For> @@ -1271,6 +1340,79 @@ export default function Page() { ) } + const ReviewTab = () => { + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const restoreScroll = () => { + const el = scroll + console.log("restoreScroll", el) + if (!el) return + + const s = view().scroll("review") + console.log("restoreScroll", s) + if (!s) return + + console.log("restoreScroll", el.scrollTop, s.y) + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll("review", next) + }) + } + + createEffect( + on( + () => diffs().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + <SessionReview + scrollRef={(el) => { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + open={view().review.open()} + onOpenChange={view().review.setOpen} + classes={{ + root: "pb-40", + header: "px-6", + container: "px-6", + }} + diffs={diffs()} + diffStyle={layout.review.diffStyle()} + onDiffStyleChange={layout.review.setDiffStyle} + /> + ) + } + return ( <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <Header /> @@ -1300,6 +1442,8 @@ export default function Page() { diffs={diffs()} diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} + open={view().review.open()} + onOpenChange={view().review.setOpen} classes={{ root: "pb-32", header: "px-4", @@ -1373,7 +1517,7 @@ export default function Page() { <Tabs value={activeTab()} onChange={openTab}> <div class="sticky top-0 shrink-0 flex"> <Tabs.List> - <Show when={diffs().length}> + <Show when={reviewTab()}> <Tabs.Trigger value="review"> <div class="flex items-center gap-3"> <Show when={diffs()}> @@ -1425,19 +1569,10 @@ export default function Page() { </div> </Tabs.List> </div> - <Show when={diffs().length}> + <Show when={reviewTab()}> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <SessionReview - classes={{ - root: "pb-40", - header: "px-6", - container: "px-6", - }} - diffs={diffs()} - diffStyle={layout.review.diffStyle()} - onDiffStyleChange={layout.review.setDiffStyle} - /> + <ReviewTab /> </div> </Tabs.Content> </Show> @@ -1452,7 +1587,7 @@ export default function Page() { {(tab) => { let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined - let pendingTop: number | undefined + let pending: { x: number; y: number } | undefined const path = createMemo(() => file.pathFromTab(tab)) const state = createMemo(() => { @@ -1480,30 +1615,30 @@ export default function Page() { const restoreScroll = () => { const el = scroll - const p = path() - if (!el || !p) return + if (!el) return + + const s = view()?.scroll(tab) + if (!s) return - const top = file.scrollTop(p) - if (top === undefined) return - if (el.scrollTop === top) return - el.scrollTop = top + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x } const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - const p = path() - if (!p) return - - pendingTop = event.currentTarget.scrollTop + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } if (scrollFrame !== undefined) return scrollFrame = requestAnimationFrame(() => { scrollFrame = undefined - const top = pendingTop - pendingTop = undefined - if (top === undefined) return + const next = pending + pending = undefined + if (!next) return - file.setScrollTop(p, top) + view().setScroll(tab, next) }) } |
