diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index 3439d366cee..791b33b4a9e 100644
--- a/packages/app/src/components/file-tree.tsx
+++ b/packages/app/src/components/file-tree.tsx
@@ -1,111 +1,121 @@
-import { useLocal, type LocalFile } from "@/context/local"
+import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
+import { createEffect, For, Match, splitProps, Switch, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
+import type { FileNode } from "@opencode-ai/sdk/v2"
export default function FileTree(props: {
path: string
class?: string
nodeClass?: string
level?: number
- onFileClick?: (file: LocalFile) => void
+ onFileClick?: (file: FileNode) => void
}) {
- const local = useLocal()
+ const file = useFile()
const level = props.level ?? 0
- const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
- {
- const evt = e as globalThis.DragEvent
- evt.dataTransfer!.effectAllowed = "copy"
- evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
+ createEffect(() => {
+ void file.tree.list(props.path)
+ })
- // Create custom drag image without margins
- const dragImage = document.createElement("div")
- dragImage.className =
- "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
- dragImage.style.position = "absolute"
- dragImage.style.top = "-1000px"
+ const Node = (
+ p: ParentProps &
+ ComponentProps<"div"> &
+ ComponentProps<"button"> & {
+ node: FileNode
+ as?: "div" | "button"
+ },
+ ) => {
+ const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
+ return (
+ {
+ e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
+ e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
- // Copy only the icon and text content without padding
- const icon = e.currentTarget.querySelector("svg")
- const text = e.currentTarget.querySelector("span")
- if (icon && text) {
- dragImage.innerHTML = icon.outerHTML + text.outerHTML
- }
+ const dragImage = document.createElement("div")
+ dragImage.className =
+ "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
+ dragImage.style.position = "absolute"
+ dragImage.style.top = "-1000px"
- document.body.appendChild(dragImage)
- evt.dataTransfer!.setDragImage(dragImage, 0, 12)
- setTimeout(() => document.body.removeChild(dragImage), 0)
- }}
- {...p}
- >
- {p.children}
- document.body.removeChild(dragImage), 0)
}}
+ {...rest}
>
- {p.node.name}
-
- {/* */}
- {/* */}
- {/* */}
-
- )
+ {local.children}
+
+ {local.node.name}
+
+
+ )
+ }
return (
-
-
- {(node) => (
-
-
-
- (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
- >
-
-
-
-
-
-
-
-
-
-
-
-
- props.onFileClick?.(node)}>
-
-
-
-
-
-
- )}
+
+
+ {(node) => {
+ const expanded = () => file.tree.state(node.path)?.expanded ?? false
+ return (
+
+
+
+ (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ props.onFileClick?.(node)}>
+
+
+
+
+
+
+ )
+ }}
)
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index e70e0790cbc..0020dc8032d 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -165,6 +165,25 @@ export function SessionHeader() {
+
+
+
>()
+
+ const [tree, setTree] = createStore<{
+ node: Record
+ dir: Record
+ }>({
+ node: {},
+ dir: { "": { expanded: true } },
+ })
+
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted(
@@ -208,14 +226,152 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
+ function normalizeDir(input: string) {
+ return normalize(input).replace(/\/+$/, "")
+ }
+
+ function ensureDir(path: string) {
+ if (tree.dir[path]) return
+ setTree("dir", path, { expanded: false })
+ }
+
+ function listDir(input: string, options?: { force?: boolean }) {
+ const dir = normalizeDir(input)
+ ensureDir(dir)
+
+ const current = tree.dir[dir]
+ if (!options?.force && current?.loaded) return Promise.resolve()
+
+ const pending = treeInflight.get(dir)
+ if (pending) return pending
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = true
+ draft.error = undefined
+ }),
+ )
+
+ const promise = sdk.client.file
+ .list({ path: dir })
+ .then((x) => {
+ const nodes = x.data ?? []
+ const prevChildren = tree.dir[dir]?.children ?? []
+ const nextChildren = nodes.map((node) => node.path)
+ const nextSet = new Set(nextChildren)
+
+ setTree(
+ "node",
+ produce((draft) => {
+ const removedDirs: string[] = []
+
+ for (const child of prevChildren) {
+ if (nextSet.has(child)) continue
+ const existing = draft[child]
+ if (existing?.type === "directory") removedDirs.push(child)
+ delete draft[child]
+ }
+
+ if (removedDirs.length > 0) {
+ const keys = Object.keys(draft)
+ for (const key of keys) {
+ for (const removed of removedDirs) {
+ if (!key.startsWith(removed + "/")) continue
+ delete draft[key]
+ break
+ }
+ }
+ }
+
+ for (const node of nodes) {
+ draft[node.path] = node
+ }
+ }),
+ )
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loaded = true
+ draft.loading = false
+ draft.children = nextChildren
+ }),
+ )
+ })
+ .catch((e) => {
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = false
+ draft.error = e.message
+ }),
+ )
+ showToast({
+ variant: "error",
+ title: "Failed to list files",
+ description: e.message,
+ })
+ })
+ .finally(() => {
+ treeInflight.delete(dir)
+ })
+
+ treeInflight.set(dir, promise)
+ return promise
+ }
+
+ function expandDir(input: string) {
+ const dir = normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", true)
+ void listDir(dir)
+ }
+
+ function collapseDir(input: string) {
+ const dir = normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", false)
+ }
+
+ function dirState(input: string) {
+ const dir = normalizeDir(input)
+ return tree.dir[dir]
+ }
+
+ function children(input: string) {
+ const dir = normalizeDir(input)
+ const ids = tree.dir[dir]?.children
+ if (!ids) return []
+ const out: FileNode[] = []
+ for (const id of ids) {
+ const node = tree.node[id]
+ if (node) out.push(node)
+ }
+ return out
+ }
+
const stop = sdk.event.listen((e) => {
const event = e.details
if (event.type !== "file.watcher.updated") return
const path = normalize(event.properties.file)
if (!path) return
if (path.startsWith(".git/")) return
- if (!store.file[path]) return
- load(path, { force: true })
+
+ if (store.file[path]) {
+ load(path, { force: true })
+ }
+
+ const kind = event.properties.event
+ if (kind !== "add" && kind !== "unlink") return
+
+ const parent = path.split("/").slice(0, -1).join("/")
+ if (!tree.dir[parent]?.loaded) return
+
+ listDir(parent, { force: true })
})
const get = (input: string) => store.file[normalize(input)]
@@ -265,6 +421,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalize,
tab,
pathFromTab,
+ tree: {
+ list: listDir,
+ refresh: (input: string) => listDir(input, { force: true }),
+ state: dirState,
+ children,
+ expand: expandDir,
+ collapse: collapseDir,
+ toggle(input: string) {
+ if (dirState(input)?.expanded) {
+ collapseDir(input)
+ return
+ }
+ expandDir(input)
+ },
+ },
get,
load,
scrollTop,
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index e454f6cfad1..d7da8207c55 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -64,6 +64,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: true,
diffStyle: "split" as ReviewDiffStyle,
},
+ fileTree: {
+ opened: false,
+ width: 260,
+ },
session: {
width: 600,
},
@@ -230,6 +234,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "opened", (x) => !x)
},
},
+ fileTree: {
+ opened: createMemo(() => store.fileTree?.opened ?? false),
+ width: createMemo(() => store.fileTree?.width ?? 260),
+ open() {
+ if (!store.fileTree) {
+ setStore("fileTree", { opened: true, width: 260 })
+ return
+ }
+ setStore("fileTree", "opened", true)
+ },
+ close() {
+ if (!store.fileTree) {
+ setStore("fileTree", { opened: false, width: 260 })
+ return
+ }
+ setStore("fileTree", "opened", false)
+ },
+ toggle() {
+ if (!store.fileTree) {
+ setStore("fileTree", { opened: true, width: 260 })
+ return
+ }
+ setStore("fileTree", "opened", (x) => !x)
+ },
+ resize(width: number) {
+ if (!store.fileTree) {
+ setStore("fileTree", { opened: true, width })
+ return
+ }
+ setStore("fileTree", "width", width)
+ },
+ },
session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index 3af840556ee..6642e323636 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -1,7 +1,7 @@
-import { createStore, produce, reconcile } from "solid-js/store"
-import { batch, createMemo, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { batch, createMemo } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
-import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
+import type { Model, Provider } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -9,23 +9,6 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { persisted } from "@/utils/persist"
-import { showToast } from "@opencode-ai/ui/toast"
-
-export type LocalFile = FileNode &
- Partial<{
- loaded: boolean
- pinned: boolean
- expanded: boolean
- content: FileContent
- selection: { startLine: number; startChar: number; endLine: number; endChar: number }
- scrollTop: number
- view: "raw" | "diff-unified" | "diff-split"
- folded: string[]
- selectedChange: number
- status: FileStatus
- }>
-export type TextSelection = LocalFile["selection"]
-export type View = LocalFile["view"]
export type LocalModel = Omit & {
provider: Provider
@@ -33,9 +16,6 @@ export type LocalModel = Omit & {
}
export type ModelKey = { providerID: string; modelID: string }
-export type FileContext = { type: "file"; path: string; selection?: TextSelection }
-export type ContextItem = FileContext
-
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
@@ -323,234 +303,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})()
- const file = (() => {
- const [store, setStore] = createStore<{
- node: Record
- }>({
- node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
- })
-
- // const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
- // const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
-
- // createEffect((prev: FileStatus[]) => {
- // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
- // for (const p of removed) {
- // setStore(
- // "node",
- // p.path,
- // produce((draft) => {
- // draft.status = undefined
- // draft.view = "raw"
- // }),
- // )
- // load(p.path)
- // }
- // for (const p of sync.data.changes) {
- // if (store.node[p.path] === undefined) {
- // fetch(p.path).then(() => {
- // if (store.node[p.path] === undefined) return
- // setStore("node", p.path, "status", p)
- // })
- // } else {
- // setStore("node", p.path, "status", p)
- // }
- // }
- // return sync.data.changes
- // }, sync.data.changes)
-
- // const changed = (path: string) => {
- // const node = store.node[path]
- // if (node?.status) return true
- // const set = changeset()
- // if (set.has(path)) return true
- // for (const p of set) {
- // if (p.startsWith(path ? path + "/" : "")) return true
- // }
- // return false
- // }
-
- // const resetNode = (path: string) => {
- // setStore("node", path, {
- // loaded: undefined,
- // pinned: undefined,
- // content: undefined,
- // selection: undefined,
- // scrollTop: undefined,
- // folded: undefined,
- // view: undefined,
- // selectedChange: undefined,
- // })
- // }
-
- const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
-
- const load = async (path: string) => {
- const relativePath = relative(path)
- await sdk.client.file
- .read({ path: relativePath })
- .then((x) => {
- if (!store.node[relativePath]) return
- setStore(
- "node",
- relativePath,
- produce((draft) => {
- draft.loaded = true
- draft.content = x.data
- }),
- )
- })
- .catch((e) => {
- showToast({
- variant: "error",
- title: "Failed to load file",
- description: e.message,
- })
- })
- }
-
- const fetch = async (path: string) => {
- const relativePath = relative(path)
- const parent = relativePath.split("/").slice(0, -1).join("/")
- if (parent) {
- await list(parent)
- }
- }
-
- const init = async (path: string) => {
- const relativePath = relative(path)
- if (!store.node[relativePath]) await fetch(path)
- if (store.node[relativePath]?.loaded) return
- return load(relativePath)
- }
-
- const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
- const relativePath = relative(path)
- if (!store.node[relativePath]) await fetch(path)
- // setStore("opened", (x) => {
- // if (x.includes(relativePath)) return x
- // return [
- // ...opened()
- // .filter((x) => x.pinned)
- // .map((x) => x.path),
- // relativePath,
- // ]
- // })
- // setStore("active", relativePath)
- // context.addActive()
- if (options?.pinned) setStore("node", path, "pinned", true)
- if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
- if (store.node[relativePath]?.loaded) return
- return load(relativePath)
- }
-
- const list = async (path: string) => {
- return sdk.client.file
- .list({ path: path + "/" })
- .then((x) => {
- setStore(
- "node",
- produce((draft) => {
- x.data!.forEach((node) => {
- if (node.path in draft) return
- draft[node.path] = node
- })
- }),
- )
- })
- .catch(() => {})
- }
-
- const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
- const searchFilesAndDirectories = (query: string) =>
- sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
-
- const unsub = sdk.event.listen((e) => {
- const event = e.details
- switch (event.type) {
- case "file.watcher.updated":
- const relativePath = relative(event.properties.file)
- if (relativePath.startsWith(".git/")) return
- if (store.node[relativePath]) load(relativePath)
- break
- }
- })
- onCleanup(unsub)
-
- return {
- node: async (path: string) => {
- if (!store.node[path] || !store.node[path].loaded) {
- await init(path)
- }
- return store.node[path]
- },
- update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
- open,
- load,
- init,
- expand(path: string) {
- setStore("node", path, "expanded", true)
- if (store.node[path]?.loaded) return
- setStore("node", path, "loaded", true)
- list(path)
- },
- collapse(path: string) {
- setStore("node", path, "expanded", false)
- },
- select(path: string, selection: TextSelection | undefined) {
- setStore("node", path, "selection", selection)
- },
- scroll(path: string, scrollTop: number) {
- setStore("node", path, "scrollTop", scrollTop)
- },
- view(path: string): View {
- const n = store.node[path]
- return n && n.view ? n.view : "raw"
- },
- setView(path: string, view: View) {
- setStore("node", path, "view", view)
- },
- unfold(path: string, key: string) {
- setStore("node", path, "folded", (xs) => {
- const a = xs ?? []
- if (a.includes(key)) return a
- return [...a, key]
- })
- },
- fold(path: string, key: string) {
- setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
- },
- folded(path: string) {
- const n = store.node[path]
- return n && n.folded ? n.folded : []
- },
- changeIndex(path: string) {
- return store.node[path]?.selectedChange
- },
- setChangeIndex(path: string, index: number | undefined) {
- setStore("node", path, "selectedChange", index)
- },
- // changes,
- // changed,
- children(path: string) {
- return Object.values(store.node).filter(
- (x) =>
- x.path.startsWith(path) &&
- x.path !== path &&
- !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
- )
- },
- searchFiles,
- searchFilesAndDirectories,
- relative,
- }
- })()
-
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
model,
agent,
- file,
}
return result
},
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index a0de9021c9d..74e7b123ea3 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -28,6 +28,7 @@ import { Terminal } from "@/components/terminal"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
+import FileTree from "@/components/file-tree"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { useCommand } from "@/context/command"
@@ -640,7 +641,9 @@ export default function Page() {
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
const showTabs = createMemo(
- () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
+ () =>
+ layout.review.opened() &&
+ (diffs().length > 0 || tabs().all().length > 0 || contextOpen() || layout.fileTree.opened()),
)
const activeTab = createMemo(() => {
@@ -955,268 +958,317 @@ export default function Page() {
{/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Review
-
-
- {info()?.summary?.files ?? 0}
-
+
+
+
+
+
+ Files
+
+
+ openTab(file.tab(node.path))} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Review
+
+
+ {info()?.summary?.files ?? 0}
+
+
+
-
-
-
-
-
- tabs().close("context")} />
-
- }
- hideCloseButton
- >
-
-
-
-
- {(tab) => }
-
-
-
- dialog.show(() => )}
+
+
+
+
+ tabs().close("context")} />
+
+ }
+ hideCloseButton
+ >
+
+
+
+
+ {(tab) => }
+
+
+
+ dialog.show(() => )}
+ />
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {(tab) => {
- let scroll: HTMLDivElement | undefined
- let scrollFrame: number | undefined
- let pending: { x: number; y: number } | undefined
-
- const path = createMemo(() => file.pathFromTab(tab))
- const state = createMemo(() => {
- const p = path()
- if (!p) return
- return file.get(p)
- })
- const contents = createMemo(() => state()?.content?.content ?? "")
- const cacheKey = createMemo(() => checksum(contents()))
- const isImage = createMemo(() => {
- const c = state()?.content
- return (
- c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
- )
- })
- const isSvg = createMemo(() => {
- const c = state()?.content
- return c?.mimeType === "image/svg+xml"
- })
- const svgContent = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding === "base64") return base64Decode(c.content)
- return c.content
- })
- const svgPreviewUrl = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
- })
- const imageDataUrl = createMemo(() => {
- if (!isImage()) return
- const c = state()?.content
- return `data:${c?.mimeType};base64,${c?.content}`
- })
- const selectedLines = createMemo(() => {
- const p = path()
- if (!p) return null
- return file.selectedLines(p) ?? null
- })
- const selection = createMemo(() => {
- const range = selectedLines()
- if (!range) return
- return selectionFromLines(range)
- })
- const selectionLabel = createMemo(() => {
- const sel = selection()
- if (!sel) return
- if (sel.startLine === sel.endLine) return `L${sel.startLine}`
- return `L${sel.startLine}-${sel.endLine}`
- })
-
- const restoreScroll = (retries = 0) => {
- const el = scroll
- if (!el) return
-
- const s = view()?.scroll(tab)
- if (!s) return
-
- // Wait for content to be scrollable - content may not have rendered yet
- if (el.scrollHeight <= el.clientHeight && retries < 10) {
- requestAnimationFrame(() => restoreScroll(retries + 1))
- return
+
+
+
+
+
+
+
+
+
+
+
+ {(tab) => {
+ let scroll: HTMLDivElement | undefined
+ let scrollFrame: number | undefined
+ let pending: { x: number; y: number } | undefined
+
+ const path = createMemo(() => file.pathFromTab(tab))
+ const state = createMemo(() => {
+ const p = path()
+ if (!p) return
+ return file.get(p)
+ })
+ const contents = createMemo(() => state()?.content?.content ?? "")
+ const cacheKey = createMemo(() => checksum(contents()))
+ const isImage = createMemo(() => {
+ const c = state()?.content
+ return (
+ c?.encoding === "base64" &&
+ c?.mimeType?.startsWith("image/") &&
+ c?.mimeType !== "image/svg+xml"
+ )
+ })
+ const isSvg = createMemo(() => {
+ const c = state()?.content
+ return c?.mimeType === "image/svg+xml"
+ })
+ const svgContent = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding === "base64") return base64Decode(c.content)
+ return c.content
+ })
+ const svgPreviewUrl = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
+ })
+ const imageDataUrl = createMemo(() => {
+ if (!isImage()) return
+ const c = state()?.content
+ return `data:${c?.mimeType};base64,${c?.content}`
+ })
+ const selectedLines = createMemo(() => {
+ const p = path()
+ if (!p) return null
+ return file.selectedLines(p) ?? null
+ })
+ const selection = createMemo(() => {
+ const range = selectedLines()
+ if (!range) return
+ return selectionFromLines(range)
+ })
+ const selectionLabel = createMemo(() => {
+ const sel = selection()
+ if (!sel) return
+ if (sel.startLine === sel.endLine) return `L${sel.startLine}`
+ return `L${sel.startLine}-${sel.endLine}`
+ })
+
+ const restoreScroll = (retries = 0) => {
+ const el = scroll
+ if (!el) return
+
+ const s = view()?.scroll(tab)
+ if (!s) return
+
+ // Wait for content to be scrollable - content may not have rendered yet
+ if (el.scrollHeight <= el.clientHeight && retries < 10) {
+ requestAnimationFrame(() => restoreScroll(retries + 1))
+ return
+ }
+
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
- 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 (scrollFrame !== undefined) return
+
+ scrollFrame = requestAnimationFrame(() => {
+ scrollFrame = undefined
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- pending = {
- x: event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
+ const next = pending
+ pending = undefined
+ if (!next) return
+
+ view().setScroll(tab, next)
+ })
}
- if (scrollFrame !== undefined) return
- scrollFrame = requestAnimationFrame(() => {
- scrollFrame = undefined
+ createEffect(
+ on(
+ () => state()?.loaded,
+ (loaded) => {
+ if (!loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
- const next = pending
- pending = undefined
- if (!next) return
+ createEffect(
+ on(
+ () => file.ready(),
+ (ready) => {
+ if (!ready) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
- view().setScroll(tab, next)
+ createEffect(
+ on(
+ () => tabs().active() === tab,
+ (active) => {
+ if (!active) return
+ if (!state()?.loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ if (scrollFrame === undefined) return
+ cancelAnimationFrame(scrollFrame)
})
- }
- createEffect(
- on(
- () => state()?.loaded,
- (loaded) => {
- if (!loaded) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => file.ready(),
- (ready) => {
- if (!ready) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => tabs().active() === tab,
- (active) => {
- if (!active) return
- if (!state()?.loaded) return
- requestAnimationFrame(restoreScroll)
- },
- ),
- )
-
- onCleanup(() => {
- if (scrollFrame === undefined) return
- cancelAnimationFrame(scrollFrame)
- })
-
- return (
- {
- scroll = el
- restoreScroll()
- }}
- onScroll={handleScroll}
- >
-
- {(sel) => (
-
-
-
- )}
-
-
-
-
-
})
-
-
-
-
+ return (
+
{
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ >
+
+ {(sel) => (
+
+
+
+ )}
+
+
+
+
+
})
+
+
+
+
+
{
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range)
+ }}
+ overflow="scroll"
+ class="select-text"
+ />
+
+
+
})
+
+
+
+
+
-
-
-
})
-
-
-
-
-
- {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, range)
- }}
- overflow="scroll"
- class="select-text pb-40"
- />
-
-
- Loading...
-
-
- {(err) => {err()}
}
-
-
-
- )
- }}
-
-
-
-
- {(tab) => {
- const path = createMemo(() => file.pathFromTab(tab()))
- return (
-
- {(p) => }
-
- )
- }}
-
-
-
+
+
+ Loading...
+
+
+ {(err) => {err()}
}
+
+
+
+ )
+ }}
+
+
+
+
+ {(tab) => {
+ const path = createMemo(() => file.pathFromTab(tab()))
+ return (
+
+ {(p) => }
+
+ )
+ }}
+
+
+
+