fix(app): file tree not staying in sync across projects/sessions
This commit is contained in:
@@ -33,6 +33,8 @@ type SessionTabs = {
|
|||||||
type SessionView = {
|
type SessionView = {
|
||||||
scroll: Record<string, SessionScroll>
|
scroll: Record<string, SessionScroll>
|
||||||
reviewOpen?: string[]
|
reviewOpen?: string[]
|
||||||
|
pendingMessage?: string
|
||||||
|
pendingMessageAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabHandoff = {
|
type TabHandoff = {
|
||||||
@@ -128,6 +130,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const MAX_SESSION_KEYS = 50
|
const MAX_SESSION_KEYS = 50
|
||||||
|
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||||
const meta = { active: undefined as string | undefined, pruned: false }
|
const meta = { active: undefined as string | undefined, pruned: false }
|
||||||
const used = new Map<string, number>()
|
const used = new Map<string, number>()
|
||||||
|
|
||||||
@@ -555,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("mobileSidebar", "opened", (x) => !x)
|
setStore("mobileSidebar", "opened", (x) => !x)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
pendingMessage: {
|
||||||
|
set(sessionKey: string, messageID: string) {
|
||||||
|
const at = Date.now()
|
||||||
|
touch(sessionKey)
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, {
|
||||||
|
scroll: {},
|
||||||
|
pendingMessage: messageID,
|
||||||
|
pendingMessageAt: at,
|
||||||
|
})
|
||||||
|
prune(meta.active ?? sessionKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"sessionView",
|
||||||
|
sessionKey,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.pendingMessage = messageID
|
||||||
|
draft.pendingMessageAt = at
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
consume(sessionKey: string) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
const message = current?.pendingMessage
|
||||||
|
const at = current?.pendingMessageAt
|
||||||
|
if (!message || !at) return
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"sessionView",
|
||||||
|
sessionKey,
|
||||||
|
produce((draft) => {
|
||||||
|
delete draft.pendingMessage
|
||||||
|
delete draft.pendingMessageAt
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
|
||||||
|
return message
|
||||||
|
},
|
||||||
|
},
|
||||||
view(sessionKey: string | Accessor<string>) {
|
view(sessionKey: string | Accessor<string>) {
|
||||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||||
|
|
||||||
|
|||||||
@@ -1864,7 +1864,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
getLabel={messageLabel}
|
getLabel={messageLabel}
|
||||||
onMessageSelect={(message) => {
|
onMessageSelect={(message) => {
|
||||||
if (!isActive()) {
|
if (!isActive()) {
|
||||||
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
|
layout.pendingMessage.set(
|
||||||
|
`${base64Encode(props.session.directory)}/${props.session.id}`,
|
||||||
|
message.id,
|
||||||
|
)
|
||||||
navigate(`${props.slug}/session/${props.session.id}`)
|
navigate(`${props.slug}/session/${props.session.id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,31 @@ import { same } from "@/utils/same"
|
|||||||
|
|
||||||
type DiffStyle = "unified" | "split"
|
type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
|
type HandoffSession = {
|
||||||
|
prompt: string
|
||||||
|
files: Record<string, SelectedLineRange | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDOFF_MAX = 40
|
||||||
|
|
||||||
const handoff = {
|
const handoff = {
|
||||||
prompt: "",
|
session: new Map<string, HandoffSession>(),
|
||||||
terminals: [] as string[],
|
terminal: new Map<string, string[]>(),
|
||||||
files: {} as Record<string, SelectedLineRange | null>,
|
}
|
||||||
|
|
||||||
|
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
||||||
|
map.delete(key)
|
||||||
|
map.set(key, value)
|
||||||
|
while (map.size > HANDOFF_MAX) {
|
||||||
|
const first = map.keys().next().value
|
||||||
|
if (first === undefined) return
|
||||||
|
map.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
||||||
|
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
|
||||||
|
touch(handoff.session, key, { ...prev, ...patch })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionReviewTabProps {
|
interface SessionReviewTabProps {
|
||||||
@@ -793,8 +814,10 @@ export default function Page() {
|
|||||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!params.id) return
|
sdk.directory
|
||||||
sync.session.sync(params.id)
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
sync.session.sync(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -862,10 +885,22 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => params.id,
|
sessionKey,
|
||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("expanded", {})
|
setStore("expanded", {})
|
||||||
|
setUi("autoCreated", false)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => params.dir,
|
||||||
|
(dir) => {
|
||||||
|
if (!dir) return
|
||||||
|
setStore("newSessionWorktree", "main")
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
@@ -1373,12 +1408,15 @@ export default function Page() {
|
|||||||
activeDiff: undefined as string | undefined,
|
activeDiff: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const reviewScroll = () => tree.reviewScroll
|
createEffect(
|
||||||
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
|
on(
|
||||||
const pendingDiff = () => tree.pendingDiff
|
sessionKey,
|
||||||
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
|
() => {
|
||||||
const activeDiff = () => tree.activeDiff
|
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
|
||||||
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value)
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const showAllFiles = () => {
|
const showAllFiles = () => {
|
||||||
if (fileTreeTab() !== "changes") return
|
if (fileTreeTab() !== "changes") return
|
||||||
@@ -1399,8 +1437,8 @@ export default function Page() {
|
|||||||
view={view}
|
view={view}
|
||||||
diffStyle={layout.review.diffStyle()}
|
diffStyle={layout.review.diffStyle()}
|
||||||
onDiffStyleChange={layout.review.setDiffStyle}
|
onDiffStyleChange={layout.review.setDiffStyle}
|
||||||
onScrollRef={setReviewScroll}
|
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||||
focusedFile={activeDiff()}
|
focusedFile={tree.activeDiff}
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||||
comments={comments.all()}
|
comments={comments.all()}
|
||||||
focusedComment={comments.focus()}
|
focusedComment={comments.focus()}
|
||||||
@@ -1450,7 +1488,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reviewDiffTop = (path: string) => {
|
const reviewDiffTop = (path: string) => {
|
||||||
const root = reviewScroll()
|
const root = tree.reviewScroll
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
const id = reviewDiffId(path)
|
const id = reviewDiffId(path)
|
||||||
@@ -1466,7 +1504,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollToReviewDiff = (path: string) => {
|
const scrollToReviewDiff = (path: string) => {
|
||||||
const root = reviewScroll()
|
const root = tree.reviewScroll
|
||||||
if (!root) return false
|
if (!root) return false
|
||||||
|
|
||||||
const top = reviewDiffTop(path)
|
const top = reviewDiffTop(path)
|
||||||
@@ -1480,24 +1518,23 @@ export default function Page() {
|
|||||||
const focusReviewDiff = (path: string) => {
|
const focusReviewDiff = (path: string) => {
|
||||||
const current = view().review.open() ?? []
|
const current = view().review.open() ?? []
|
||||||
if (!current.includes(path)) view().review.setOpen([...current, path])
|
if (!current.includes(path)) view().review.setOpen([...current, path])
|
||||||
setActiveDiff(path)
|
setTree({ activeDiff: path, pendingDiff: path })
|
||||||
setPendingDiff(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const pending = pendingDiff()
|
const pending = tree.pendingDiff
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
if (!reviewScroll()) return
|
if (!tree.reviewScroll) return
|
||||||
if (!diffsReady()) return
|
if (!diffsReady()) return
|
||||||
|
|
||||||
const attempt = (count: number) => {
|
const attempt = (count: number) => {
|
||||||
if (pendingDiff() !== pending) return
|
if (tree.pendingDiff !== pending) return
|
||||||
if (count > 60) {
|
if (count > 60) {
|
||||||
setPendingDiff(undefined)
|
setTree("pendingDiff", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = reviewScroll()
|
const root = tree.reviewScroll
|
||||||
if (!root) {
|
if (!root) {
|
||||||
requestAnimationFrame(() => attempt(count + 1))
|
requestAnimationFrame(() => attempt(count + 1))
|
||||||
return
|
return
|
||||||
@@ -1515,7 +1552,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(root.scrollTop - top) <= 1) {
|
if (Math.abs(root.scrollTop - top) <= 1) {
|
||||||
setPendingDiff(undefined)
|
setTree("pendingDiff", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1558,13 +1595,17 @@ export default function Page() {
|
|||||||
void sync.session.diff(id)
|
void sync.session.diff(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let treeDir: string | undefined
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
const dir = sdk.directory
|
||||||
if (!isDesktop()) return
|
if (!isDesktop()) return
|
||||||
if (!layout.fileTree.opened()) return
|
if (!layout.fileTree.opened()) return
|
||||||
if (sync.status === "loading") return
|
if (sync.status === "loading") return
|
||||||
|
|
||||||
fileTreeTab()
|
fileTreeTab()
|
||||||
void file.tree.list("")
|
const refresh = treeDir !== dir
|
||||||
|
treeDir = dir
|
||||||
|
void (refresh ? file.tree.refresh("") : file.tree.list(""))
|
||||||
})
|
})
|
||||||
|
|
||||||
const autoScroll = createAutoScroll({
|
const autoScroll = createAutoScroll({
|
||||||
@@ -1599,6 +1640,18 @@ export default function Page() {
|
|||||||
let scrollSpyFrame: number | undefined
|
let scrollSpyFrame: number | undefined
|
||||||
let scrollSpyTarget: HTMLDivElement | undefined
|
let scrollSpyTarget: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
sessionKey,
|
||||||
|
() => {
|
||||||
|
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||||
|
scrollSpyFrame = undefined
|
||||||
|
scrollSpyTarget = undefined
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const anchor = (id: string) => `message-${id}`
|
const anchor = (id: string) => `message-${id}`
|
||||||
|
|
||||||
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||||
@@ -1713,20 +1766,14 @@ export default function Page() {
|
|||||||
window.history.replaceState(null, "", `#${anchor(id)}`)
|
window.history.replaceState(null, "", `#${anchor(id)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
const sessionID = params.id
|
on(sessionKey, (key) => {
|
||||||
if (!sessionID) return
|
if (!params.id) return
|
||||||
const raw = sessionStorage.getItem("opencode.pendingMessage")
|
const messageID = layout.pendingMessage.consume(key)
|
||||||
if (!raw) return
|
if (!messageID) return
|
||||||
const parts = raw.split("|")
|
setUi("pendingMessage", messageID)
|
||||||
const pendingSessionID = parts[0]
|
}),
|
||||||
const messageID = parts[1]
|
)
|
||||||
if (!pendingSessionID || !messageID) return
|
|
||||||
if (pendingSessionID !== sessionID) return
|
|
||||||
|
|
||||||
sessionStorage.removeItem("opencode.pendingMessage")
|
|
||||||
setUi("pendingMessage", messageID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||||
const root = scroller
|
const root = scroller
|
||||||
@@ -1940,7 +1987,7 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
handoff.prompt = previewPrompt()
|
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -1960,20 +2007,22 @@ export default function Page() {
|
|||||||
return language.t("terminal.title")
|
return language.t("terminal.title")
|
||||||
}
|
}
|
||||||
|
|
||||||
handoff.terminals = terminal.all().map(label)
|
touch(handoff.terminal, params.dir!, terminal.all().map(label))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!file.ready()) return
|
if (!file.ready()) return
|
||||||
handoff.files = Object.fromEntries(
|
setSessionHandoff(sessionKey(), {
|
||||||
tabs()
|
files: Object.fromEntries(
|
||||||
.all()
|
tabs()
|
||||||
.flatMap((tab) => {
|
.all()
|
||||||
const path = file.pathFromTab(tab)
|
.flatMap((tab) => {
|
||||||
if (!path) return []
|
const path = file.pathFromTab(tab)
|
||||||
return [[path, file.selectedLines(path) ?? null] as const]
|
if (!path) return []
|
||||||
}),
|
return [[path, file.selectedLines(path) ?? null] as const]
|
||||||
)
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -2049,7 +2098,7 @@ export default function Page() {
|
|||||||
diffs={diffs}
|
diffs={diffs}
|
||||||
view={view}
|
view={view}
|
||||||
diffStyle="unified"
|
diffStyle="unified"
|
||||||
focusedFile={activeDiff()}
|
focusedFile={tree.activeDiff}
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||||
comments={comments.all()}
|
comments={comments.all()}
|
||||||
focusedComment={comments.focus()}
|
focusedComment={comments.focus()}
|
||||||
@@ -2483,7 +2532,7 @@ export default function Page() {
|
|||||||
when={prompt.ready()}
|
when={prompt.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||||
{handoff.prompt || language.t("prompt.loading")}
|
{handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -2734,7 +2783,7 @@ export default function Page() {
|
|||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
if (file.ready()) return file.selectedLines(p) ?? null
|
if (file.ready()) return file.selectedLines(p) ?? null
|
||||||
return handoff.files[p] ?? null
|
return handoff.session.get(sessionKey())?.files[p] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
let wrap: HTMLDivElement | undefined
|
let wrap: HTMLDivElement | undefined
|
||||||
@@ -3228,7 +3277,7 @@ export default function Page() {
|
|||||||
allowed={diffFiles()}
|
allowed={diffFiles()}
|
||||||
kinds={kinds()}
|
kinds={kinds()}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
active={activeDiff()}
|
active={tree.activeDiff}
|
||||||
onFileClick={(node) => focusReviewDiff(node.path)}
|
onFileClick={(node) => focusReviewDiff(node.path)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -3288,7 +3337,7 @@ export default function Page() {
|
|||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col h-full pointer-events-none">
|
<div class="flex flex-col h-full pointer-events-none">
|
||||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||||
<For each={handoff.terminals}>
|
<For each={handoff.terminal.get(params.dir!) ?? []}>
|
||||||
{(title) => (
|
{(title) => (
|
||||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
Reference in New Issue
Block a user