Adam 1 месяц назад
Родитель
Сommit
b8872d9d20

+ 70 - 8
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) {

+ 7 - 15
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()}
           >

+ 167 - 32
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)
                       })
                     }
 

+ 13 - 10
packages/ui/src/components/session-review.tsx

@@ -20,6 +20,10 @@ export interface SessionReviewProps {
   split?: boolean
   diffStyle?: SessionReviewDiffStyle
   onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
+  open?: string[]
+  onOpenChange?: (open: string[]) => void
+  scrollRef?: (el: HTMLDivElement) => void
+  onScroll?: JSX.EventHandlerUnion<HTMLDivElement, Event>
   class?: string
   classList?: Record<string, boolean | undefined>
   classes?: { root?: string; header?: string; container?: string }
@@ -33,26 +37,25 @@ export const SessionReview = (props: SessionReviewProps) => {
     open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
   })
 
+  const open = () => props.open ?? store.open
   const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
 
   const handleChange = (open: string[]) => {
+    props.onOpenChange?.(open)
+    if (props.open !== undefined) return
     setStore("open", open)
   }
 
   const handleExpandOrCollapseAll = () => {
-    if (store.open.length > 0) {
-      setStore("open", [])
-    } else {
-      setStore(
-        "open",
-        props.diffs.map((d) => d.file),
-      )
-    }
+    const next = open().length > 0 ? [] : props.diffs.map((d) => d.file)
+    handleChange(next)
   }
 
   return (
     <div
       data-component="session-review"
+      ref={props.scrollRef}
+      onScroll={props.onScroll}
       classList={{
         ...(props.classList ?? {}),
         [props.classes?.root ?? ""]: !!props.classes?.root,
@@ -78,7 +81,7 @@ export const SessionReview = (props: SessionReviewProps) => {
           </Show>
           <Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
             <Switch>
-              <Match when={store.open.length > 0}>Collapse all</Match>
+              <Match when={open().length > 0}>Collapse all</Match>
               <Match when={true}>Expand all</Match>
             </Switch>
           </Button>
@@ -91,7 +94,7 @@ export const SessionReview = (props: SessionReviewProps) => {
           [props.classes?.container ?? ""]: !!props.classes?.container,
         }}
       >
-        <Accordion multiple value={store.open} onChange={handleChange}>
+        <Accordion multiple value={open()} onChange={handleChange}>
           <For each={props.diffs}>
             {(diff) => (
               <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">