diff options
| author | Adam <[email protected]> | 2025-10-03 09:04:28 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-03 09:04:28 -0500 |
| commit | 3fa280d21878ae391674a21758199df3d2d8c3b5 (patch) | |
| tree | f70c6ecafffeecc8e7a59dc9acef66c59a9ea54a /packages/app/src/components | |
| parent | 1d58b5548287a3e32ffce3abdcf0f2db08fdb155 (diff) | |
| download | opencode-3fa280d21878ae391674a21758199df3d2d8c3b5.tar.gz opencode-3fa280d21878ae391674a21758199df3d2d8c3b5.zip | |
chore: app -> desktop
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/code.tsx | 846 | ||||
| -rw-r--r-- | packages/app/src/components/editor-pane.tsx | 381 | ||||
| -rw-r--r-- | packages/app/src/components/file-tree.tsx | 110 | ||||
| -rw-r--r-- | packages/app/src/components/markdown.tsx | 23 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-form.tsx | 295 | ||||
| -rw-r--r-- | packages/app/src/components/resizeable-pane.tsx | 217 | ||||
| -rw-r--r-- | packages/app/src/components/select-dialog.tsx | 225 | ||||
| -rw-r--r-- | packages/app/src/components/select.tsx | 107 | ||||
| -rw-r--r-- | packages/app/src/components/session-list.tsx | 28 | ||||
| -rw-r--r-- | packages/app/src/components/session-timeline.tsx | 459 | ||||
| -rw-r--r-- | packages/app/src/components/sidebar-nav.tsx | 48 |
11 files changed, 0 insertions, 2739 deletions
diff --git a/packages/app/src/components/code.tsx b/packages/app/src/components/code.tsx deleted file mode 100644 index 40a40aa9a..000000000 --- a/packages/app/src/components/code.tsx +++ /dev/null @@ -1,846 +0,0 @@ -import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki" -import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" -import { useLocal, useShiki } from "@/context" -import type { TextSelection } from "@/context/local" -import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" - -type DefinedSelection = Exclude<TextSelection, undefined> - -interface Props extends ComponentProps<"div"> { - code: string - path: string -} - -export function Code(props: Props) { - const ctx = useLocal() - const highlighter = useShiki() - const [local, others] = splitProps(props, ["class", "classList", "code", "path"]) - const lang = createMemo(() => { - const ext = getFileExtension(local.path) - if (ext in bundledLanguages) return ext - return "text" - }) - - let container: HTMLDivElement | undefined - let isProgrammaticSelection = false - - const ranges = createMemo<DefinedSelection[]>(() => { - const items = ctx.context.all() as Array<{ type: "file"; path: string; selection?: DefinedSelection }> - const result: DefinedSelection[] = [] - for (const item of items) { - if (item.path !== local.path) continue - const selection = item.selection - if (!selection) continue - result.push(selection) - } - return result - }) - - const createLineNumberTransformer = (selections: DefinedSelection[]): ShikiTransformer => { - const highlighted = new Set<number>() - for (const selection of selections) { - const startLine = selection.startLine - const endLine = selection.endLine - const start = Math.max(1, Math.min(startLine, endLine)) - const end = Math.max(start, Math.max(startLine, endLine)) - const count = end - start + 1 - if (count <= 0) continue - const values = Array.from({ length: count }, (_, index) => start + index) - for (const value of values) highlighted.add(value) - } - return { - name: "line-number-highlight", - line(node, index) { - if (!highlighted.has(index)) return - this.addClassToHast(node, "line-number-highlight") - const children = node.children - if (!Array.isArray(children)) return - for (const child of children) { - if (!child || typeof child !== "object") continue - const element = child as { type?: string; properties?: { className?: string[] } } - if (element.type !== "element") continue - const className = element.properties?.className - if (!Array.isArray(className)) continue - const matches = className.includes("diff-oldln") || className.includes("diff-newln") - if (!matches) continue - if (className.includes("line-number-highlight")) continue - className.push("line-number-highlight") - } - }, - } - } - - const [html] = createResource( - () => ranges(), - async (activeRanges) => { - if (!highlighter.getLoadedLanguages().includes(lang())) { - await highlighter.loadLanguage(lang() as BundledLanguage) - } - return highlighter.codeToHtml(local.code || "", { - lang: lang() && lang() in bundledLanguages ? lang() : "text", - theme: "opencode", - transformers: [transformerUnifiedDiff(), transformerDiffGroups(), createLineNumberTransformer(activeRanges)], - }) as string - }, - ) - - onMount(() => { - if (!container) return - - let ticking = false - const onScroll = () => { - if (!container) return - if (ctx.file.active()?.path !== local.path) return - if (ticking) return - ticking = true - requestAnimationFrame(() => { - ticking = false - ctx.file.scroll(local.path, container!.scrollTop) - }) - } - - const onSelectionChange = () => { - if (!container) return - if (isProgrammaticSelection) return - if (ctx.file.active()?.path !== local.path) return - const d = getSelectionInContainer(container) - if (!d) return - const p = ctx.file.node(local.path)?.selection - if (p && p.startLine === d.sl && p.endLine === d.el && p.startChar === d.sch && p.endChar === d.ech) return - ctx.file.select(local.path, { startLine: d.sl, startChar: d.sch, endLine: d.el, endChar: d.ech }) - } - - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - const onKeyDown = (e: KeyboardEvent) => { - if (ctx.file.active()?.path !== local.path) return - const ae = document.activeElement as HTMLElement | undefined - const tag = (ae?.tagName || "").toLowerCase() - const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable) - if (inputFocused) return - if (e.getModifierState(MOD) && e.key.toLowerCase() === "a") { - e.preventDefault() - if (!container) return - const element = container.querySelector("code") as HTMLElement | undefined - if (!element) return - const lines = Array.from(element.querySelectorAll(".line")) - if (!lines.length) return - const r = document.createRange() - const last = lines[lines.length - 1] - r.selectNodeContents(last) - const lastLen = r.toString().length - ctx.file.select(local.path, { startLine: 1, startChar: 0, endLine: lines.length, endChar: lastLen }) - } - } - - container.addEventListener("scroll", onScroll) - document.addEventListener("selectionchange", onSelectionChange) - document.addEventListener("keydown", onKeyDown) - - onCleanup(() => { - container?.removeEventListener("scroll", onScroll) - document.removeEventListener("selectionchange", onSelectionChange) - document.removeEventListener("keydown", onKeyDown) - }) - }) - - // Restore scroll position from store when content is ready - createEffect(() => { - const content = html() - if (!container || !content) return - const top = ctx.file.node(local.path)?.scrollTop - if (top !== undefined && container.scrollTop !== top) container.scrollTop = top - }) - - // Sync selection from store -> DOM - createEffect(() => { - const content = html() - if (!container || !content) return - if (ctx.file.active()?.path !== local.path) return - const codeEl = container.querySelector("code") as HTMLElement | undefined - if (!codeEl) return - const target = ctx.file.node(local.path)?.selection - const current = getSelectionInContainer(container) - const sel = window.getSelection() - if (!sel) return - if (!target) { - if (current) { - isProgrammaticSelection = true - sel.removeAllRanges() - queueMicrotask(() => { - isProgrammaticSelection = false - }) - } - return - } - const matches = !!( - current && - current.sl === target.startLine && - current.sch === target.startChar && - current.el === target.endLine && - current.ech === target.endChar - ) - if (matches) return - const lines = Array.from(codeEl.querySelectorAll(".line")) - if (lines.length === 0) return - let sIdx = Math.max(0, target.startLine - 1) - let eIdx = Math.max(0, target.endLine - 1) - let sChar = Math.max(0, target.startChar || 0) - let eChar = Math.max(0, target.endChar || 0) - if (sIdx > eIdx || (sIdx === eIdx && sChar > eChar)) { - const ti = sIdx - sIdx = eIdx - eIdx = ti - const tc = sChar - sChar = eChar - eChar = tc - } - if (eChar === 0 && eIdx > sIdx) { - eIdx = eIdx - 1 - eChar = Number.POSITIVE_INFINITY - } - if (sIdx >= lines.length) return - if (eIdx >= lines.length) eIdx = lines.length - 1 - const s = getNodeOffsetInLine(lines[sIdx], sChar) ?? { node: lines[sIdx], offset: 0 } - const e = getNodeOffsetInLine(lines[eIdx], eChar) ?? { node: lines[eIdx], offset: lines[eIdx].childNodes.length } - const range = document.createRange() - range.setStart(s.node, s.offset) - range.setEnd(e.node, e.offset) - isProgrammaticSelection = true - sel.removeAllRanges() - sel.addRange(range) - queueMicrotask(() => { - isProgrammaticSelection = false - }) - }) - - // Build/toggle split layout and apply folding (both unified and split) - createEffect(() => { - const content = html() - if (!container || !content) return - const view = ctx.file.view(local.path) - - const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre")) - if (pres.length === 0) return - const originalPre = pres[0] - - const split = container.querySelector<HTMLElement>(".diff-split") - if (view === "diff-split") { - applySplitDiff(container) - const next = container.querySelector<HTMLElement>(".diff-split") - if (next) next.style.display = "" - originalPre.style.display = "none" - } else { - if (split) split.style.display = "none" - originalPre.style.display = "" - } - - const expanded = ctx.file.folded(local.path) - if (view === "diff-split") { - const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code") - const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code") - if (left) - applyDiffFolding(left, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "left" }) - if (right) - applyDiffFolding(right, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "right" }) - } else { - const code = container.querySelector<HTMLElement>("pre code") - if (code) - applyDiffFolding(code, 3, { - expanded, - onExpand: (key) => ctx.file.unfold(local.path, key), - }) - } - }) - - // Highlight groups + scroll coupling - const clearHighlights = () => { - if (!container) return - container.querySelectorAll<HTMLElement>(".diff-selected").forEach((el) => el.classList.remove("diff-selected")) - } - - const applyHighlight = (idx: number, scroll?: boolean) => { - if (!container) return - const view = ctx.file.view(local.path) - if (view === "raw") return - - clearHighlights() - - const nodes: HTMLElement[] = [] - if (view === "diff-split") { - const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code") - const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code") - if (left) - nodes.push(...Array.from(left.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="remove"]`))) - if (right) - nodes.push(...Array.from(right.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="add"]`))) - } else { - const code = container.querySelector<HTMLElement>("pre code") - if (code) nodes.push(...Array.from(code.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"]`))) - } - - for (const n of nodes) n.classList.add("diff-selected") - if (scroll && nodes.length) nodes[0].scrollIntoView({ block: "center", behavior: "smooth" }) - } - - const countGroups = () => { - if (!container) return 0 - const code = container.querySelector<HTMLElement>("pre code") - if (!code) return 0 - const set = new Set<string>() - for (const el of Array.from(code.querySelectorAll<HTMLElement>(".diff-line[data-chgrp]"))) { - const v = el.getAttribute("data-chgrp") - if (v != undefined) set.add(v) - } - return set.size - } - - let lastIdx: number | undefined = undefined - let lastView: string | undefined - let lastContent: string | undefined - let lastRawIdx: number | undefined = undefined - createEffect(() => { - const content = html() - if (!container || !content) return - const view = ctx.file.view(local.path) - const raw = ctx.file.changeIndex(local.path) - if (raw === undefined) return - const total = countGroups() - if (total <= 0) return - const next = ((raw % total) + total) % total - - const navigated = lastRawIdx !== undefined && lastRawIdx !== raw - - if (next !== raw) { - ctx.file.setChangeIndex(local.path, next) - applyHighlight(next, true) - } else { - if (lastView !== view || lastContent !== content) applyHighlight(next) - if ((lastIdx !== undefined && lastIdx !== next) || navigated) applyHighlight(next, true) - } - - lastRawIdx = raw - lastIdx = next - lastView = view - lastContent = content - }) - - return ( - <div - ref={(el) => { - container = el - }} - innerHTML={html()} - class=" - font-mono text-xs tracking-wide overflow-y-auto h-full - [&]:[counter-reset:line] - [&_pre]:focus-visible:outline-none - [&_pre]:overflow-x-auto [&_pre]:no-scrollbar - [&_code]:min-w-full [&_code]:inline-block - [&_.tab]:relative - [&_.tab::before]:content['⇥'] - [&_.tab::before]:absolute - [&_.tab::before]:opacity-0 - [&_.space]:relative - [&_.space::before]:content-['·'] - [&_.space::before]:absolute - [&_.space::before]:opacity-0 - [&_.line]:inline-block [&_.line]:w-full - [&_.line]:hover:bg-background-element - [&_.line::before]:sticky [&_.line::before]:left-0 - [&_.line::before]:w-12 [&_.line::before]:pr-4 - [&_.line::before]:z-10 - [&_.line::before]:bg-background-panel - [&_.line::before]:text-text-muted/60 - [&_.line::before]:text-right [&_.line::before]:inline-block - [&_.line::before]:select-none - [&_.line::before]:[counter-increment:line] - [&_.line::before]:content-[counter(line)] - [&_.line-number-highlight]:bg-accent/20 - [&_.line-number-highlight::before]:bg-accent/40! - [&_.line-number-highlight::before]:text-background-panel! - [&_code.code-diff_.line::before]:content-[''] - [&_code.code-diff_.line::before]:w-0 - [&_code.code-diff_.line::before]:pr-0 - [&_.diff-split_code.code-diff::before]:w-10 - [&_.diff-split_.diff-newln]:left-0 - [&_.diff-oldln]:sticky [&_.diff-oldln]:left-0 - [&_.diff-oldln]:w-10 [&_.diff-oldln]:pr-2 - [&_.diff-oldln]:z-40 - [&_.diff-oldln]:text-text-muted/60 - [&_.diff-oldln]:text-right [&_.diff-oldln]:inline-block - [&_.diff-oldln]:select-none - [&_.diff-oldln]:bg-background-panel - [&_.diff-newln]:sticky [&_.diff-newln]:left-10 - [&_.diff-newln]:w-10 [&_.diff-newln]:pr-2 - [&_.diff-newln]:z-40 - [&_.diff-newln]:text-text-muted/60 - [&_.diff-newln]:text-right [&_.diff-newln]:inline-block - [&_.diff-newln]:select-none - [&_.diff-newln]:bg-background-panel - [&_.diff-add]:bg-success/20! - [&_.diff-add.diff-selected]:bg-success/50! - [&_.diff-add_.diff-oldln]:bg-success! - [&_.diff-add_.diff-oldln]:text-background-panel! - [&_.diff-add_.diff-newln]:bg-success! - [&_.diff-add_.diff-newln]:text-background-panel! - [&_.diff-remove]:bg-error/20! - [&_.diff-remove.diff-selected]:bg-error/50! - [&_.diff-remove_.diff-newln]:bg-error! - [&_.diff-remove_.diff-newln]:text-background-panel! - [&_.diff-remove_.diff-oldln]:bg-error! - [&_.diff-remove_.diff-oldln]:text-background-panel! - [&_.diff-sign]:inline-block [&_.diff-sign]:px-2 [&_.diff-sign]:select-none - [&_.diff-blank]:bg-background-element - [&_.diff-blank_.diff-oldln]:bg-background-element - [&_.diff-blank_.diff-newln]:bg-background-element - [&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative - [&_.diff-collapsed]:cursor-pointer [&_.diff-collapsed]:select-none - [&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40! - [&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info - [&_.diff-collapsed]:text-xs - [&_.diff-collapsed_.diff-oldln]:bg-info! - [&_.diff-collapsed_.diff-newln]:bg-info! - " - classList={{ - ...(local.classList || {}), - [local.class ?? ""]: !!local.class, - }} - {...others} - ></div> - ) -} - -function transformerUnifiedDiff(): ShikiTransformer { - const kinds = new Map<number, string>() - const meta = new Map<number, { old?: number; new?: number; sign?: string }>() - let isDiff = false - - return { - name: "unified-diff", - preprocess(input) { - kinds.clear() - meta.clear() - isDiff = false - - const ls = input.split(/\r?\n/) - const out: Array<string> = [] - let oldNo = 0 - let newNo = 0 - let inHunk = false - - for (let i = 0; i < ls.length; i++) { - const s = ls[i] - - const m = s.match(/^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/) - if (m) { - isDiff = true - inHunk = true - oldNo = parseInt(m[1], 10) - newNo = parseInt(m[3], 10) - continue - } - - if ( - /^diff --git /.test(s) || - /^Index: /.test(s) || - /^--- /.test(s) || - /^\+\+\+ /.test(s) || - /^[=]{3,}$/.test(s) || - /^\*{3,}$/.test(s) || - /^\\ No newline at end of file$/.test(s) - ) { - isDiff = true - continue - } - - if (!inHunk) { - out.push(s) - continue - } - - if (/^\+/.test(s)) { - out.push(s) - const ln = out.length - kinds.set(ln, "add") - meta.set(ln, { new: newNo, sign: "+" }) - newNo++ - continue - } - - if (/^-/.test(s)) { - out.push(s) - const ln = out.length - kinds.set(ln, "remove") - meta.set(ln, { old: oldNo, sign: "-" }) - oldNo++ - continue - } - - if (/^ /.test(s)) { - out.push(s) - const ln = out.length - kinds.set(ln, "context") - meta.set(ln, { old: oldNo, new: newNo }) - oldNo++ - newNo++ - continue - } - - // fallback in hunks - out.push(s) - } - - return out.join("\n").trimEnd() - }, - code(node) { - if (isDiff) this.addClassToHast(node, "code-diff") - }, - pre(node) { - if (isDiff) this.addClassToHast(node, "code-diff") - }, - line(node, line) { - if (!isDiff) return - const kind = kinds.get(line) - if (!kind) return - - const m = meta.get(line) || {} - - this.addClassToHast(node, "diff-line") - this.addClassToHast(node, `diff-${kind}`) - node.properties = node.properties || {} - ;(node.properties as any)["data-diff"] = kind - if (m.old != undefined) (node.properties as any)["data-old"] = String(m.old) - if (m.new != undefined) (node.properties as any)["data-new"] = String(m.new) - - const oldSpan = { - type: "element", - tagName: "span", - properties: { className: ["diff-oldln"] }, - children: [{ type: "text", value: m.old != undefined ? String(m.old) : " " }], - } - const newSpan = { - type: "element", - tagName: "span", - properties: { className: ["diff-newln"] }, - children: [{ type: "text", value: m.new != undefined ? String(m.new) : " " }], - } - - if (kind === "add" || kind === "remove" || kind === "context") { - const first = (node.children && (node.children as any[])[0]) as any - if (first && first.type === "element" && first.children && first.children.length > 0) { - const t = first.children[0] - if (t && t.type === "text" && typeof t.value === "string" && t.value.length > 0) { - const ch = t.value[0] - if (ch === "+" || ch === "-" || ch === " ") t.value = t.value.slice(1) - } - } - } - - const signSpan = { - type: "element", - tagName: "span", - properties: { className: ["diff-sign"] }, - children: [{ type: "text", value: (m as any).sign || " " }], - } - - // @ts-expect-error hast typing across versions - node.children = [oldSpan, newSpan, signSpan, ...(node.children || [])] - }, - } -} - -function transformerDiffGroups(): ShikiTransformer { - let group = -1 - let inGroup = false - return { - name: "diff-groups", - pre() { - group = -1 - inGroup = false - }, - line(node) { - const props = (node.properties || {}) as any - const kind = props["data-diff"] as string | undefined - if (kind === "add" || kind === "remove") { - if (!inGroup) { - group += 1 - inGroup = true - } - ;(node.properties as any)["data-chgrp"] = String(group) - } else { - inGroup = false - } - }, - } -} - -function applyDiffFolding( - root: HTMLElement, - context = 3, - options?: { expanded?: string[]; onExpand?: (key: string) => void; side?: "left" | "right" }, -) { - if (!root.classList.contains("code-diff")) return - - // Cleanup: unwrap previous collapsed blocks and remove toggles - const blocks = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed-block")) - for (const block of blocks) { - const p = block.parentNode - if (!p) { - block.remove() - continue - } - while (block.firstChild) p.insertBefore(block.firstChild, block) - block.remove() - } - const toggles = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed")) - for (const t of toggles) t.remove() - - const lines = Array.from(root.querySelectorAll<HTMLElement>(".diff-line")) - if (lines.length === 0) return - - const n = lines.length - const isChange = lines.map((l) => l.dataset["diff"] === "add" || l.dataset["diff"] === "remove") - const isContext = lines.map((l) => l.dataset["diff"] === "context") - if (!isChange.some(Boolean)) return - - const visible = new Array(n).fill(false) as boolean[] - for (let i = 0; i < n; i++) if (isChange[i]) visible[i] = true - for (let i = 0; i < n; i++) { - if (isChange[i]) { - const s = Math.max(0, i - context) - const e = Math.min(n - 1, i + context) - for (let j = s; j <= e; j++) if (isContext[j]) visible[j] = true - } - } - - type Range = { start: number; end: number } - const ranges: Range[] = [] - let i = 0 - while (i < n) { - if (!visible[i] && isContext[i]) { - let j = i - while (j + 1 < n && !visible[j + 1] && isContext[j + 1]) j++ - ranges.push({ start: i, end: j }) - i = j + 1 - } else { - i++ - } - } - - for (const r of ranges) { - const start = lines[r.start] - const end = lines[r.end] - const count = r.end - r.start + 1 - const minCollapse = 20 - if (count < minCollapse) { - continue - } - - // Wrap the entire collapsed chunk (including trailing newline) so it takes no space - const block = document.createElement("span") - block.className = "diff-collapsed-block" - start.parentElement?.insertBefore(block, start) - - let cur: Node | undefined = start - while (cur) { - const next: Node | undefined = cur.nextSibling || undefined - block.appendChild(cur) - if (cur === end) { - // Also move the newline after the last line into the block - if (next && next.nodeType === Node.TEXT_NODE && (next.textContent || "").startsWith("\n")) { - block.appendChild(next) - } - break - } - cur = next - } - - block.style.display = "none" - const row = document.createElement("span") - row.className = "line diff-collapsed" - row.setAttribute("data-kind", "collapsed") - row.setAttribute("data-count", String(count)) - row.setAttribute("tabindex", "0") - row.setAttribute("role", "button") - - const oldln = document.createElement("span") - oldln.className = "diff-oldln" - oldln.textContent = " " - - const newln = document.createElement("span") - newln.className = "diff-newln" - newln.textContent = " " - - const sign = document.createElement("span") - sign.className = "diff-sign" - sign.textContent = "…" - - const label = document.createElement("span") - label.textContent = `show ${count} unchanged line${count > 1 ? "s" : ""}` - - const key = `o${start.dataset["old"] || ""}-${end.dataset["old"] || ""}:n${start.dataset["new"] || ""}-${end.dataset["new"] || ""}` - - const show = (record = true) => { - if (record) options?.onExpand?.(key) - const p = block.parentNode - if (p) { - while (block.firstChild) p.insertBefore(block.firstChild, block) - block.remove() - } - row.remove() - } - - row.addEventListener("click", () => show(true)) - row.addEventListener("keydown", (ev) => { - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault() - show(true) - } - }) - - block.parentElement?.insertBefore(row, block) - if (!options?.side || options.side === "left") row.appendChild(oldln) - if (!options?.side || options.side === "right") row.appendChild(newln) - row.appendChild(sign) - row.appendChild(label) - - if (options?.expanded && options.expanded.includes(key)) { - show(false) - } - } -} - -function applySplitDiff(container: HTMLElement) { - const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre")) - if (pres.length === 0) return - const originalPre = pres[0] - const originalCode = originalPre.querySelector("code") as HTMLElement | undefined - if (!originalCode || !originalCode.classList.contains("code-diff")) return - - // Rebuild split each time to match current content - const existing = container.querySelector<HTMLElement>(".diff-split") - if (existing) existing.remove() - - const grid = document.createElement("div") - grid.className = "diff-split grid grid-cols-2 gap-x-6" - - const makeColumn = () => { - const pre = document.createElement("pre") - pre.className = originalPre.className - const code = document.createElement("code") - code.className = originalCode.className - pre.appendChild(code) - return { pre, code } - } - - const left = makeColumn() - const right = makeColumn() - - // Helpers - const cloneSide = (line: HTMLElement, side: "old" | "new"): HTMLElement => { - const clone = line.cloneNode(true) as HTMLElement - const oldln = clone.querySelector(".diff-oldln") - const newln = clone.querySelector(".diff-newln") - if (side === "old") { - if (newln) newln.remove() - } else { - if (oldln) oldln.remove() - } - return clone - } - - const blankLine = (side: "old" | "new", kind: "add" | "remove"): HTMLElement => { - const span = document.createElement("span") - span.className = "line diff-line diff-blank" - span.setAttribute("data-diff", kind) - const ln = document.createElement("span") - ln.className = side === "old" ? "diff-oldln" : "diff-newln" - ln.textContent = " " - span.appendChild(ln) - return span - } - - const lines = Array.from(originalCode.querySelectorAll<HTMLElement>(".diff-line")) - let i = 0 - while (i < lines.length) { - const cur = lines[i] - const kind = cur.dataset["diff"] - - if (kind === "context") { - left.code.appendChild(cloneSide(cur, "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(cur, "new")) - right.code.appendChild(document.createTextNode("\n")) - i++ - continue - } - - if (kind === "remove") { - // Batch consecutive removes and following adds, then pair - const removes: HTMLElement[] = [] - const adds: HTMLElement[] = [] - let j = i - while (j < lines.length && lines[j].dataset["diff"] === "remove") { - removes.push(lines[j]) - j++ - } - let k = j - while (k < lines.length && lines[k].dataset["diff"] === "add") { - adds.push(lines[k]) - k++ - } - - const pairs = Math.min(removes.length, adds.length) - for (let p = 0; p < pairs; p++) { - left.code.appendChild(cloneSide(removes[p], "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(adds[p], "new")) - right.code.appendChild(document.createTextNode("\n")) - } - for (let p = pairs; p < removes.length; p++) { - left.code.appendChild(cloneSide(removes[p], "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(blankLine("new", "remove")) - right.code.appendChild(document.createTextNode("\n")) - } - for (let p = pairs; p < adds.length; p++) { - left.code.appendChild(blankLine("old", "add")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(adds[p], "new")) - right.code.appendChild(document.createTextNode("\n")) - } - - i = k - continue - } - - if (kind === "add") { - // Run of adds not preceded by removes - const adds: HTMLElement[] = [] - let j = i - while (j < lines.length && lines[j].dataset["diff"] === "add") { - adds.push(lines[j]) - j++ - } - for (let p = 0; p < adds.length; p++) { - left.code.appendChild(blankLine("old", "add")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(adds[p], "new")) - right.code.appendChild(document.createTextNode("\n")) - } - i = j - continue - } - - // Any other kind: mirror as context - left.code.appendChild(cloneSide(cur, "old")) - left.code.appendChild(document.createTextNode("\n")) - right.code.appendChild(cloneSide(cur, "new")) - right.code.appendChild(document.createTextNode("\n")) - i++ - } - - grid.appendChild(left.pre) - grid.appendChild(right.pre) - container.appendChild(grid) -} diff --git a/packages/app/src/components/editor-pane.tsx b/packages/app/src/components/editor-pane.tsx deleted file mode 100644 index faf70811d..000000000 --- a/packages/app/src/components/editor-pane.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js" -import { Tabs } from "@/ui/tabs" -import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - closestCenter, - createSortable, - useDragDropContext, -} from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import type { LocalFile } from "@/context/local" -import { Code } from "@/components/code" -import PromptForm from "@/components/prompt-form" -import { useLocal, useSDK, useSync } from "@/context" -import { getFilename } from "@/utils" -import type { JSX } from "solid-js" - -interface EditorPaneProps { - layoutKey: string - timelinePane: string - onFileClick: (file: LocalFile) => void - onOpenModelSelect: () => void - onInputRefChange: (element: HTMLTextAreaElement | null) => void -} - -export default function EditorPane(props: EditorPaneProps): JSX.Element { - const [localProps] = splitProps(props, [ - "layoutKey", - "timelinePane", - "onFileClick", - "onOpenModelSelect", - "onInputRefChange", - ]) - const local = useLocal() - const sdk = useSDK() - const sync = useSync() - const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined) - - const navigateChange = (dir: 1 | -1) => { - const active = local.file.active() - if (!active) return - const current = local.file.changeIndex(active.path) - const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir - local.file.setChangeIndex(active.path, next) - } - - const handleTabChange = (path: string) => { - local.file.open(path) - } - - const handleTabClose = (file: LocalFile) => { - local.file.close(file.path) - } - - const handlePromptSubmit = async (prompt: string) => { - const existingSession = local.layout.visible(localProps.layoutKey, localProps.timelinePane) - ? local.session.active() - : undefined - let session = existingSession - if (!session) { - const created = await sdk.session.create() - session = created.data ?? undefined - } - if (!session) return - local.session.setActive(session.id) - local.layout.show(localProps.layoutKey, localProps.timelinePane) - - await sdk.session.prompt({ - path: { id: session.id }, - body: { - agent: local.agent.current()!.name, - model: { - modelID: local.model.current()!.id, - providerID: local.model.current()!.provider.id, - }, - parts: [ - { - type: "text", - text: prompt, - }, - ...(local.context.active() - ? [ - { - type: "file" as const, - mime: "text/plain", - url: `file://${local.context.active()!.absolute}`, - filename: local.context.active()!.name, - source: { - type: "file" as const, - text: { - value: "@" + local.context.active()!.name, - start: 0, - end: 0, - }, - path: local.context.active()!.absolute, - }, - }, - ] - : []), - ...local.context.all().flatMap((file) => [ - { - type: "file" as const, - mime: "text/plain", - url: `file://${sync.absolute(file.path)}${file.selection ? `?start=${file.selection.startLine}&end=${file.selection.endLine}` : ""}`, - filename: getFilename(file.path), - source: { - type: "file" as const, - text: { - value: "@" + getFilename(file.path), - start: 0, - end: 0, - }, - path: sync.absolute(file.path), - }, - }, - ]), - ], - }, - }) - } - - const handleDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setActiveItem(id) - } - - const handleDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const currentFiles = local.file.opened().map((file) => file.path) - const fromIndex = currentFiles.indexOf(draggable.id.toString()) - const toIndex = currentFiles.indexOf(droppable.id.toString()) - if (fromIndex !== toIndex) { - local.file.move(draggable.id.toString(), toIndex) - } - } - } - - const handleDragEnd = () => { - setActiveItem(undefined) - } - - return ( - <div class="relative flex h-full flex-col"> - <Logo size={64} variant="ornate" class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2" /> - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs - class="relative grow w-full flex flex-col h-full" - value={local.file.active()?.path} - onChange={handleTabChange} - > - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List class="grow"> - <SortableProvider ids={local.file.opened().map((file) => file.path)}> - <For each={local.file.opened()}> - {(file) => ( - <SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} /> - )} - </For> - </SortableProvider> - </Tabs.List> - <div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40"> - <Show when={local.file.active() && local.file.active()!.content?.diff}> - {(() => { - const activeFile = local.file.active()! - const view = local.file.view(activeFile.path) - return ( - <div class="flex items-center gap-1"> - <Show when={view !== "raw"}> - <div class="mr-1 flex items-center gap-1"> - <Tooltip value="Previous change" placement="bottom"> - <IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}> - <Icon name="arrow-up" size={14} /> - </IconButton> - </Tooltip> - <Tooltip value="Next change" placement="bottom"> - <IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}> - <Icon name="arrow-down" size={14} /> - </IconButton> - </Tooltip> - </div> - </Show> - <Tooltip value="Raw" placement="bottom"> - <IconButton - size="xs" - variant="ghost" - classList={{ - "text-text": view === "raw", - "text-text-muted/70": view !== "raw", - "bg-background-element": view === "raw", - }} - onClick={() => local.file.setView(activeFile.path, "raw")} - > - <Icon name="file-text" size={14} /> - </IconButton> - </Tooltip> - <Tooltip value="Unified diff" placement="bottom"> - <IconButton - size="xs" - variant="ghost" - classList={{ - "text-text": view === "diff-unified", - "text-text-muted/70": view !== "diff-unified", - "bg-background-element": view === "diff-unified", - }} - onClick={() => local.file.setView(activeFile.path, "diff-unified")} - > - <Icon name="checklist" size={14} /> - </IconButton> - </Tooltip> - <Tooltip value="Split diff" placement="bottom"> - <IconButton - size="xs" - variant="ghost" - classList={{ - "text-text": view === "diff-split", - "text-text-muted/70": view !== "diff-split", - "bg-background-element": view === "diff-split", - }} - onClick={() => local.file.setView(activeFile.path, "diff-split")} - > - <Icon name="columns" size={14} /> - </IconButton> - </Tooltip> - </div> - ) - })()} - </Show> - <Tooltip - value={local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "Close pane" : "Open pane"} - placement="bottom" - > - <IconButton - size="xs" - variant="ghost" - onClick={() => local.layout.toggle(localProps.layoutKey, localProps.timelinePane)} - > - <Icon - name={ - local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "close-pane" : "open-pane" - } - size={14} - /> - </IconButton> - </Tooltip> - </div> - </div> - <For each={local.file.opened()}> - {(file) => ( - <Tabs.Content value={file.path} class="grow h-full pt-1 select-text"> - {(() => { - const view = local.file.view(file.path) - const showRaw = view === "raw" || !file.content?.diff - const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") - return <Code path={file.path} code={code} class="[&_code]:pb-60" /> - })()} - </Tabs.Content> - )} - </For> - </Tabs> - <DragOverlay> - {(() => { - const id = activeItem() - if (!id) return null - const draggedFile = local.file.node(id) - if (!draggedFile) return null - return ( - <div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent"> - <TabVisual file={draggedFile} /> - </div> - ) - })()} - </DragOverlay> - </DragDropProvider> - <PromptForm - class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center" - classList={{ - "bottom-8": !!local.file.active(), - "bottom-3/8": local.file.active() === undefined, - }} - onSubmit={handlePromptSubmit} - onOpenModelSelect={localProps.onOpenModelSelect} - onInputRefChange={(element) => localProps.onInputRefChange(element ?? null)} - /> - </div> - ) -} - -function TabVisual(props: { file: LocalFile }): JSX.Element { - return ( - <div class="flex items-center gap-x-1.5"> - <FileIcon node={props.file} class="" /> - <span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}> - {props.file.name} - </span> - <span class="text-xs opacity-70"> - <Switch> - <Match when={props.file.status?.status === "modified"}> - <span class="text-primary">M</span> - </Match> - <Match when={props.file.status?.status === "added"}> - <span class="text-success">A</span> - </Match> - <Match when={props.file.status?.status === "deleted"}> - <span class="text-error">D</span> - </Match> - </Switch> - </span> - </div> - ) -} - -function SortableTab(props: { - file: LocalFile - onTabClick: (file: LocalFile) => void - onTabClose: (file: LocalFile) => void -}): JSX.Element { - const sortable = createSortable(props.file.path) - - return ( - // @ts-ignore - <div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}> - <Tooltip value={props.file.path} placement="bottom"> - <div class="relative"> - <Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}> - <TabVisual file={props.file} /> - </Tabs.Trigger> - <IconButton - class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100" - size="xs" - variant="ghost" - onClick={() => props.onTabClose(props.file)} - > - <Icon name="close" size={16} /> - </IconButton> - </div> - </Tooltip> - </div> - ) -} - -function ConstrainDragYAxis(): JSX.Element { - const context = useDragDropContext() - if (!context) return <></> - const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-y-axis", - order: 100, - callback: (transform) => ({ ...transform, y: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return <></> -} - -const getDraggableId = (event: unknown): string | undefined => { - if (typeof event !== "object" || event === null) return undefined - if (!("draggable" in event)) return undefined - const draggable = (event as { draggable?: { id?: unknown } }).draggable - if (!draggable) return undefined - return typeof draggable.id === "string" ? draggable.id : undefined -} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx deleted file mode 100644 index d31255ced..000000000 --- a/packages/app/src/components/file-tree.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useLocal } from "@/context" -import type { LocalFile } from "@/context/local" -import { Collapsible, FileIcon, Tooltip } from "@/ui" -import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js" -import { Dynamic } from "solid-js/web" - -export default function FileTree(props: { - path: string - class?: string - nodeClass?: string - level?: number - onFileClick?: (file: LocalFile) => void -}) { - const local = useLocal() - const level = props.level ?? 0 - - const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => ( - <Dynamic - component={p.as ?? "div"} - classList={{ - "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true, - "bg-background-element": local.file.active()?.path === p.node.path, - [props.nodeClass ?? ""]: !!props.nodeClass, - }} - style={`padding-left: ${level * 10}px`} - draggable={true} - onDragStart={(e: any) => { - const evt = e as globalThis.DragEvent - evt.dataTransfer!.effectAllowed = "copy" - evt.dataTransfer!.setData("text/plain", `file:${p.node.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" - - // 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 - } - - document.body.appendChild(dragImage) - evt.dataTransfer!.setDragImage(dragImage, 0, 12) - setTimeout(() => document.body.removeChild(dragImage), 0) - }} - {...p} - > - {p.children} - <span - classList={{ - "text-xs whitespace-nowrap truncate": true, - "text-text-muted/40": p.node.ignored, - "text-text-muted/80": !p.node.ignored, - "!text-text": local.file.active()?.path === p.node.path, - "!text-primary": local.file.changed(p.node.path), - }} - > - {p.node.name} - </span> - <Show when={local.file.changed(p.node.path)}> - <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> - </Show> - </Dynamic> - ) - - return ( - <div class={`flex flex-col ${props.class}`}> - <For each={local.file.children(props.path)}> - {(node) => ( - <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right"> - <Switch> - <Match when={node.type === "directory"}> - <Collapsible - class="w-full" - forceMount={false} - open={local.file.node(node.path)?.expanded} - onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))} - > - <Collapsible.Trigger> - <Node node={node}> - <Collapsible.Arrow size={16} class="text-text-muted/60 ml-1" /> - <FileIcon - node={node} - expanded={local.file.node(node.path).expanded} - class="text-text-muted/60 -ml-1" - /> - </Node> - </Collapsible.Trigger> - <Collapsible.Content> - <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} /> - </Collapsible.Content> - </Collapsible> - </Match> - <Match when={node.type === "file"}> - <Node node={node} as="button" onClick={() => props.onFileClick?.(node)}> - <div class="w-4 shrink-0" /> - <FileIcon node={node} class="text-primary" /> - </Node> - </Match> - </Switch> - </Tooltip> - )} - </For> - </div> - ) -} diff --git a/packages/app/src/components/markdown.tsx b/packages/app/src/components/markdown.tsx deleted file mode 100644 index a60fad149..000000000 --- a/packages/app/src/components/markdown.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useMarked } from "@/context" -import { createResource } from "solid-js" - -function strip(text: string): string { - const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/ - const match = text.match(wrappedRe) - return match ? match[2] : text -} -export function Markdown(props: { text: string; class?: string }) { - const marked = useMarked() - const [html] = createResource( - () => strip(props.text), - async (markdown) => { - return marked.parse(markdown) - }, - ) - return ( - <div - class={`min-w-0 max-w-full text-xs overflow-auto no-scrollbar prose ${props.class ?? ""}`} - innerHTML={html()} - /> - ) -} diff --git a/packages/app/src/components/prompt-form.tsx b/packages/app/src/components/prompt-form.tsx deleted file mode 100644 index 9d7c45a32..000000000 --- a/packages/app/src/components/prompt-form.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui" -import { Select } from "@/components/select" -import { useLocal } from "@/context" -import type { FileContext, LocalFile } from "@/context/local" -import { getFilename } from "@/utils" -import { createSpeechRecognition } from "@/utils/speech" - -interface PromptFormProps { - class?: string - classList?: Record<string, boolean> - onSubmit: (prompt: string) => Promise<void> | void - onOpenModelSelect: () => void - onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void -} - -export default function PromptForm(props: PromptFormProps) { - const local = useLocal() - - const [prompt, setPrompt] = createSignal("") - const [isDragOver, setIsDragOver] = createSignal(false) - - const placeholderText = "Start typing or speaking..." - - const { - isSupported, - isRecording, - interim: interimTranscript, - start: startSpeech, - stop: stopSpeech, - } = createSpeechRecognition({ - onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text), - }) - - let inputRef: HTMLTextAreaElement | undefined = undefined - let overlayContainerRef: HTMLDivElement | undefined = undefined - let shouldAutoScroll = true - - const promptContent = createMemo(() => { - const base = prompt() || "" - const interim = isRecording() ? interimTranscript() : "" - if (!base && !interim) { - return <span class="text-text-muted/70">{placeholderText}</span> - } - const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ") - return ( - <> - <span class="text-text">{base}</span> - {interim && ( - <span class="text-text-muted/60 italic"> - {needsSpace ? " " : ""} - {interim} - </span> - )} - </> - ) - }) - - createEffect(() => { - prompt() - interimTranscript() - queueMicrotask(() => { - if (!inputRef) return - if (!overlayContainerRef) return - if (!shouldAutoScroll) { - overlayContainerRef.scrollTop = inputRef.scrollTop - return - } - scrollPromptToEnd() - }) - }) - - const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => { - if (event.isComposing) return - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - inputRef?.form?.requestSubmit() - } - } - - const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => { - const target = event.currentTarget - shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4 - if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop - } - - const scrollPromptToEnd = () => { - if (!inputRef) return - const maxInputScroll = inputRef.scrollHeight - inputRef.clientHeight - const next = maxInputScroll > 0 ? maxInputScroll : 0 - inputRef.scrollTop = next - if (overlayContainerRef) overlayContainerRef.scrollTop = next - shouldAutoScroll = true - } - - const handleSubmit = async (event: SubmitEvent) => { - event.preventDefault() - const currentPrompt = prompt() - setPrompt("") - shouldAutoScroll = true - if (overlayContainerRef) overlayContainerRef.scrollTop = 0 - if (inputRef) { - inputRef.scrollTop = 0 - inputRef.blur() - } - - await props.onSubmit(currentPrompt) - } - - onCleanup(() => { - props.onInputRefChange?.(undefined) - }) - - return ( - <form onSubmit={handleSubmit} class={props.class} classList={props.classList}> - <div - class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs - flex flex-col gap-1 - bg-gradient-to-b from-background-panel/90 to-background/90 - ring-1 ring-border-active/50 border border-transparent - focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary - transition-all duration-200" - classList={{ - "shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(), - "ring-2 ring-primary/60 bg-primary/5": isDragOver(), - }} - onDragEnter={(event) => { - const evt = event as unknown as globalThis.DragEvent - if (evt.dataTransfer?.types.includes("text/plain")) { - evt.preventDefault() - setIsDragOver(true) - } - }} - onDragLeave={(event) => { - if (event.currentTarget === event.target) { - setIsDragOver(false) - } - }} - onDragOver={(event) => { - const evt = event as unknown as globalThis.DragEvent - if (evt.dataTransfer?.types.includes("text/plain")) { - evt.preventDefault() - evt.dataTransfer.dropEffect = "copy" - } - }} - onDrop={(event) => { - const evt = event as unknown as globalThis.DragEvent - evt.preventDefault() - setIsDragOver(false) - - const data = evt.dataTransfer?.getData("text/plain") - if (data && data.startsWith("file:")) { - const filePath = data.slice(5) - const fileNode = local.file.node(filePath) - if (fileNode) { - local.context.add({ - type: "file", - path: filePath, - }) - } - } - }} - > - <Show when={local.context.all().length > 0 || local.context.active()}> - <div class="flex flex-wrap gap-1"> - <Show when={local.context.active()}> - <ActiveTabContextTag file={local.context.active()!} onClose={() => local.context.removeActive()} /> - </Show> - <For each={local.context.all()}> - {(file) => <FileTag file={file} onClose={() => local.context.remove(file.key)} />} - </For> - </div> - </Show> - <div class="relative"> - <textarea - ref={(element) => { - inputRef = element ?? undefined - props.onInputRefChange?.(inputRef) - }} - value={prompt()} - onInput={(event) => setPrompt(event.currentTarget.value)} - onKeyDown={handlePromptKeyDown} - onScroll={handlePromptScroll} - placeholder={placeholderText} - autocapitalize="off" - autocomplete="off" - autocorrect="off" - spellcheck={false} - class="relative w-full h-20 rounded-md px-0.5 resize-none overflow-y-auto - bg-transparent text-transparent caret-text font-light text-base - leading-relaxed focus:outline-none selection:bg-primary/20" - ></textarea> - <div - ref={(element) => { - overlayContainerRef = element ?? undefined - }} - class="pointer-events-none absolute inset-0 overflow-hidden" - > - <div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left text-text"> - {promptContent()} - </div> - </div> - </div> - <div class="flex justify-between items-center text-xs text-text-muted"> - <div class="flex gap-2 items-center"> - <Select - options={local.agent.list().map((agent) => agent.name)} - current={local.agent.current().name} - onSelect={local.agent.set} - class="uppercase" - /> - <Button onClick={() => props.onOpenModelSelect()}> - {local.model.current()?.name ?? "Select model"} - <Icon name="chevron-down" size={24} class="text-text-muted" /> - </Button> - <span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span> - </div> - <div class="flex gap-1 items-center"> - <Show when={isSupported()}> - <Tooltip value={isRecording() ? "Stop voice input" : "Start voice input"} placement="top"> - <IconButton - onClick={async (event: MouseEvent) => { - event.preventDefault() - if (isRecording()) { - stopSpeech() - } else { - startSpeech() - } - inputRef?.focus() - }} - classList={{ - "text-text-muted": !isRecording(), - "text-error! animate-pulse": isRecording(), - }} - size="xs" - variant="ghost" - > - <Icon name="mic" size={16} /> - </IconButton> - </Tooltip> - </Show> - <IconButton class="text-text-muted" size="xs" variant="ghost"> - <Icon name="photo" size={16} /> - </IconButton> - <IconButton - class="text-background-panel! bg-primary rounded-full! hover:bg-primary/90 ml-0.5" - size="xs" - variant="ghost" - type="submit" - > - <Icon name="arrow-up" size={14} /> - </IconButton> - </div> - </div> - </div> - </form> - ) -} - -const ActiveTabContextTag = (props: { file: LocalFile; onClose: () => void }) => ( - <div - class="flex items-center bg-background group/tag - border border-border-subtle/60 border-dashed - rounded-md text-xs text-text-muted" - > - <IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}> - <Icon name="file" class="group-hover/tag:hidden" size={12} /> - <Icon name="close" class="hidden group-hover/tag:block" size={12} /> - </IconButton> - <div class="pr-1 flex gap-1 items-center"> - <span>{getFilename(props.file.path)}</span> - </div> - </div> -) - -const FileTag = (props: { file: FileContext; onClose: () => void }) => ( - <div - class="flex items-center bg-background group/tag - border border-border-subtle/60 - rounded-md text-xs text-text-muted" - > - <IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}> - <FileIcon node={props.file} class="group-hover/tag:hidden size-3!" /> - <Icon name="close" class="hidden group-hover/tag:block" size={12} /> - </IconButton> - <div class="pr-1 flex gap-1 items-center"> - <span>{getFilename(props.file.path)}</span> - <Show when={props.file.selection}> - <span> - ({props.file.selection!.startLine}-{props.file.selection!.endLine}) - </span> - </Show> - </div> - </div> -) diff --git a/packages/app/src/components/resizeable-pane.tsx b/packages/app/src/components/resizeable-pane.tsx deleted file mode 100644 index 49ccc4e70..000000000 --- a/packages/app/src/components/resizeable-pane.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { batch, createContext, createMemo, createSignal, onCleanup, Show, useContext } from "solid-js" -import type { ComponentProps, JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context" - -type PaneDefault = number | { size: number; visible?: boolean } - -type LayoutContextValue = { - id: string - register: (pane: string, options: { min?: number | string; max?: number | string }) => void - size: (pane: string) => number - visible: (pane: string) => boolean - percent: (pane: string) => number - next: (pane: string) => string | undefined - startDrag: (left: string, right: string | undefined, event: MouseEvent) => void - dragging: () => string | undefined -} - -const LayoutContext = createContext<LayoutContextValue | undefined>(undefined) - -export interface ResizeableLayoutProps { - id: string - defaults: Record<string, PaneDefault> - class?: ComponentProps<"div">["class"] - classList?: ComponentProps<"div">["classList"] - children: JSX.Element -} - -export interface ResizeablePaneProps { - id: string - minSize?: number | string - maxSize?: number | string - class?: ComponentProps<"div">["class"] - classList?: ComponentProps<"div">["classList"] - children: JSX.Element -} - -export function ResizeableLayout(props: ResizeableLayoutProps) { - const local = useLocal() - const [meta, setMeta] = createStore<Record<string, { min: number; max: number; minPx?: number; maxPx?: number }>>({}) - const [dragging, setDragging] = createSignal<string>() - let container: HTMLDivElement | undefined - - local.layout.ensure(props.id, props.defaults) - - const order = createMemo(() => local.layout.order(props.id)) - const visibleOrder = createMemo(() => order().filter((pane) => local.layout.visible(props.id, pane))) - const totalVisible = createMemo(() => { - const panes = visibleOrder() - if (!panes.length) return 0 - return panes.reduce((total, pane) => total + local.layout.size(props.id, pane), 0) - }) - - const percent = (pane: string) => { - const panes = visibleOrder() - if (!panes.length) return 0 - const total = totalVisible() - if (!total) return 100 / panes.length - return (local.layout.size(props.id, pane) / total) * 100 - } - - const nextPane = (pane: string) => { - const panes = visibleOrder() - const index = panes.indexOf(pane) - if (index === -1) return undefined - return panes[index + 1] - } - - const minMax = (pane: string) => meta[pane] ?? { min: 5, max: 95 } - - const pxToPercent = (px: number, total: number) => (px / total) * 100 - - const boundsForPair = (left: string, right: string, total: number) => { - const leftMeta = minMax(left) - const rightMeta = minMax(right) - const containerWidth = container?.getBoundingClientRect().width ?? 0 - - let minLeft = leftMeta.min - let maxLeft = leftMeta.max - let minRight = rightMeta.min - let maxRight = rightMeta.max - - if (containerWidth && leftMeta.minPx !== undefined) minLeft = pxToPercent(leftMeta.minPx, containerWidth) - if (containerWidth && leftMeta.maxPx !== undefined) maxLeft = pxToPercent(leftMeta.maxPx, containerWidth) - if (containerWidth && rightMeta.minPx !== undefined) minRight = pxToPercent(rightMeta.minPx, containerWidth) - if (containerWidth && rightMeta.maxPx !== undefined) maxRight = pxToPercent(rightMeta.maxPx, containerWidth) - - const finalMinLeft = Math.max(minLeft, total - maxRight) - const finalMaxLeft = Math.min(maxLeft, total - minRight) - return { - min: Math.min(finalMinLeft, finalMaxLeft), - max: Math.max(finalMinLeft, finalMaxLeft), - } - } - - const setPair = (left: string, right: string, leftSize: number, rightSize: number) => { - batch(() => { - local.layout.setSize(props.id, left, leftSize) - local.layout.setSize(props.id, right, rightSize) - }) - } - - const startDrag = (left: string, right: string | undefined, event: MouseEvent) => { - if (!right) return - if (!container) return - const rect = container.getBoundingClientRect() - if (!rect.width) return - event.preventDefault() - const startX = event.clientX - const startLeft = local.layout.size(props.id, left) - const startRight = local.layout.size(props.id, right) - const total = startLeft + startRight - const bounds = boundsForPair(left, right, total) - const move = (moveEvent: MouseEvent) => { - const delta = ((moveEvent.clientX - startX) / rect.width) * 100 - const nextLeft = Math.max(bounds.min, Math.min(bounds.max, startLeft + delta)) - const nextRight = total - nextLeft - setPair(left, right, nextLeft, nextRight) - } - const stop = () => { - setDragging() - document.removeEventListener("mousemove", move) - document.removeEventListener("mouseup", stop) - } - setDragging(left) - document.addEventListener("mousemove", move) - document.addEventListener("mouseup", stop) - onCleanup(() => stop()) - } - - const register = (pane: string, options: { min?: number | string; max?: number | string }) => { - let min = 5 - let max = 95 - let minPx: number | undefined - let maxPx: number | undefined - - if (typeof options.min === "string" && options.min.endsWith("px")) { - minPx = parseInt(options.min) - min = 0 - } else if (typeof options.min === "number") { - min = options.min - } - - if (typeof options.max === "string" && options.max.endsWith("px")) { - maxPx = parseInt(options.max) - max = 100 - } else if (typeof options.max === "number") { - max = options.max - } - - setMeta(pane, () => ({ min, max, minPx, maxPx })) - const fallback = props.defaults[pane] - local.layout.ensurePane(props.id, pane, fallback ?? { size: min, visible: true }) - } - - const contextValue: LayoutContextValue = { - id: props.id, - register, - size: (pane) => local.layout.size(props.id, pane), - visible: (pane) => local.layout.visible(props.id, pane), - percent, - next: nextPane, - startDrag, - dragging, - } - - return ( - <LayoutContext.Provider value={contextValue}> - <div - ref={(node) => { - container = node ?? undefined - }} - class={props.class ? `relative flex h-full w-full ${props.class}` : "relative flex h-full w-full"} - classList={props.classList} - > - {props.children} - </div> - </LayoutContext.Provider> - ) -} - -export function ResizeablePane(props: ResizeablePaneProps) { - const context = useContext(LayoutContext)! - context.register(props.id, { min: props.minSize, max: props.maxSize }) - const visible = () => context.visible(props.id) - const width = () => context.percent(props.id) - const next = () => context.next(props.id) - const dragging = () => context.dragging() === props.id - - return ( - <Show when={visible()}> - <div - class={props.class ? `relative flex h-full flex-col ${props.class}` : "relative flex h-full flex-col"} - classList={props.classList} - style={{ - width: `${width()}%`, - flex: `0 0 ${width()}%`, - }} - > - {props.children} - <Show when={next()}> - <div - class="absolute top-0 -right-1 h-full w-1.5 cursor-col-resize z-50 group" - onMouseDown={(event) => context.startDrag(props.id, next(), event)} - > - <div - classList={{ - "w-0.5 h-full bg-transparent transition-colors group-hover:bg-border-active": true, - "bg-border-active!": dragging(), - }} - /> - </div> - </Show> - </div> - </Show> - ) -} diff --git a/packages/app/src/components/select-dialog.tsx b/packages/app/src/components/select-dialog.tsx deleted file mode 100644 index 315fe14e5..000000000 --- a/packages/app/src/components/select-dialog.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js" -import { Dialog } from "@kobalte/core/dialog" -import { Icon, IconButton } from "@/ui" -import { createStore } from "solid-js/store" -import { entries, flatMap, groupBy, map, pipe } from "remeda" -import { createList } from "solid-list" -import fuzzysort from "fuzzysort" - -interface SelectDialogProps<T> { - items: T[] | ((filter: string) => Promise<T[]>) - key: (item: T) => string - render: (item: T) => JSX.Element - filter?: string[] - current?: T - placeholder?: string - groupBy?: (x: T) => string - onSelect?: (value: T | undefined) => void - onClose?: () => void -} - -export function SelectDialog<T>(props: SelectDialogProps<T>) { - let scrollRef: HTMLDivElement | undefined - const [store, setStore] = createStore({ - filter: "", - mouseActive: false, - }) - - const [grouped] = createResource( - () => store.filter, - async (filter) => { - const needle = filter.toLowerCase() - const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || [] - const result = pipe( - all, - (x) => { - if (!needle) return x - if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) { - return fuzzysort.go(needle, x).map((x) => x.target) as T[] - } - return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj) - }, - groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), - // mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))), - entries(), - map(([k, v]) => ({ category: k, items: v })), - ) - return result - }, - ) - const flat = createMemo(() => { - return pipe( - grouped() || [], - flatMap((x) => x.items), - ) - }) - const list = createList({ - items: () => flat().map(props.key), - initialActive: props.current ? props.key(props.current) : undefined, - loop: true, - }) - const resetSelection = () => { - const all = flat() - if (all.length === 0) return - list.setActive(props.key(all[0])) - } - - createEffect(() => { - store.filter - scrollRef?.scrollTo(0, 0) - resetSelection() - }) - - createEffect(() => { - const all = flat() - if (store.mouseActive || all.length === 0) return - if (list.active() === props.key(all[0])) { - scrollRef?.scrollTo(0, 0) - return - } - const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) - }) - - const handleInput = (value: string) => { - setStore("filter", value) - resetSelection() - } - - const handleSelect = (item: T) => { - props.onSelect?.(item) - props.onClose?.() - } - - const handleKey = (e: KeyboardEvent) => { - setStore("mouseActive", false) - - if (e.key === "Enter") { - e.preventDefault() - const selected = flat().find((x) => props.key(x) === list.active()) - if (selected) handleSelect(selected) - } else if (e.key === "Escape") { - e.preventDefault() - props.onClose?.() - } else { - list.onKeyDown(e) - } - } - - return ( - <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}> - <Dialog.Portal> - <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" /> - <Dialog.Content - class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl - shadow-[0_0_33px_rgba(0,0,0,0.8)] - bg-background border border-border-subtle/30 rounded-lg z-[101] - max-h-[60vh] flex flex-col" - > - <div class="border-b border-border-subtle/30"> - <div class="relative"> - <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" /> - <input - type="text" - value={store.filter} - onInput={(e) => handleInput(e.currentTarget.value)} - onKeyDown={handleKey} - placeholder={props.placeholder} - class="w-full pl-10 pr-4 py-2 rounded-t-md - text-sm text-text placeholder-text-muted/70 - focus:outline-none" - autofocus - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - /> - <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2"> - {/* <Show when={fileResults.loading && mode() === "files"}> - <div class="text-text-muted"> - <Icon name="refresh" size={14} class="animate-spin" /> - </div> - </Show> */} - <Show when={store.filter}> - <IconButton - size="xs" - variant="ghost" - class="text-text-muted hover:text-text" - onClick={() => { - setStore("filter", "") - resetSelection() - }} - > - <Icon name="close" size={14} /> - </IconButton> - </Show> - </div> - </div> - </div> - <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto"> - <Show - when={flat().length > 0} - fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>} - > - <For each={grouped()}> - {(group) => ( - <> - <Show when={group.category}> - <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase"> - {group.category} - </div> - </Show> - <div class="p-2"> - <For each={group.items}> - {(item) => ( - <button - data-key={props.key(item)} - onClick={() => handleSelect(item)} - onMouseMove={() => { - setStore("mouseActive", true) - list.setActive(props.key(item)) - }} - classList={{ - "w-full px-3 py-2 flex items-center gap-3": true, - "rounded-md text-left transition-colors group": true, - "bg-background-element": props.key(item) === list.active(), - }} - > - {props.render(item)} - </button> - )} - </For> - </div> - </> - )} - </For> - </Show> - </div> - <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted"> - <div class="flex items-center gap-5"> - <span class="flex items-center gap-1.5"> - <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> - ↑↓ - </kbd> - Navigate - </span> - <span class="flex items-center gap-1.5"> - <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> - ↵ - </kbd> - Select - </span> - <span class="flex items-center gap-1.5"> - <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> - ESC - </kbd> - Close - </span> - </div> - <span>{`${flat().length} results`}</span> - </div> - </Dialog.Content> - </Dialog.Portal> - </Dialog> - ) -} diff --git a/packages/app/src/components/select.tsx b/packages/app/src/components/select.tsx deleted file mode 100644 index 3df8c9999..000000000 --- a/packages/app/src/components/select.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Select as KobalteSelect } from "@kobalte/core/select" -import { createMemo } from "solid-js" -import type { ComponentProps } from "solid-js" -import { Icon } from "@/ui/icon" -import { pipe, groupBy, entries, map } from "remeda" -import { Button, type ButtonProps } from "@/ui" - -export interface SelectProps<T> { - placeholder?: string - options: T[] - current?: T - value?: (x: T) => string - label?: (x: T) => string - groupBy?: (x: T) => string - onSelect?: (value: T | undefined) => void - class?: ComponentProps<"div">["class"] - classList?: ComponentProps<"div">["classList"] -} - -export function Select<T>(props: SelectProps<T> & ButtonProps) { - const grouped = createMemo(() => { - const result = pipe( - props.options, - groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), - // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), - entries(), - map(([k, v]) => ({ category: k, options: v })), - ) - return result - }) - - return ( - <KobalteSelect<T, { category: string; options: T[] }> - value={props.current} - options={grouped()} - optionValue={(x) => (props.value ? props.value(x) : (x as string))} - optionTextValue={(x) => (props.label ? props.label(x) : (x as string))} - optionGroupChildren="options" - placeholder={props.placeholder} - sectionComponent={(props) => ( - <KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2"> - {props.section.rawValue.category} - </KobalteSelect.Section> - )} - itemComponent={(itemProps) => ( - <KobalteSelect.Item - classList={{ - "relative flex cursor-pointer select-none items-center": true, - "rounded-sm px-2 py-0.5 text-xs outline-none text-text": true, - "transition-colors data-[disabled]:pointer-events-none": true, - "data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true, - [props.class ?? ""]: !!props.class, - }} - {...itemProps} - > - <KobalteSelect.ItemLabel> - {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} - </KobalteSelect.ItemLabel> - <KobalteSelect.ItemIndicator class="ml-auto"> - <Icon name="checkmark" size={16} /> - </KobalteSelect.ItemIndicator> - </KobalteSelect.Item> - )} - onChange={(v) => { - props.onSelect?.(v ?? undefined) - }} - > - <KobalteSelect.Trigger - as={Button} - size={props.size || "sm"} - variant={props.variant || "secondary"} - classList={{ - ...(props.classList ?? {}), - [props.class ?? ""]: !!props.class, - }} - > - <KobalteSelect.Value<T>> - {(state) => { - const selected = state.selectedOption() ?? props.current - if (!selected) return props.placeholder || "" - if (props.label) return props.label(selected) - return selected as string - }} - </KobalteSelect.Value> - <KobalteSelect.Icon - classList={{ - "size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true, - }} - > - <Icon name="chevron-down" size={24} /> - </KobalteSelect.Icon> - </KobalteSelect.Trigger> - <KobalteSelect.Portal> - <KobalteSelect.Content - classList={{ - "min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true, - "bg-background-panel p-1 shadow-md z-50": true, - "data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true, - "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true, - }} - > - <KobalteSelect.Listbox class="overflow-y-auto max-h-48" /> - </KobalteSelect.Content> - </KobalteSelect.Portal> - </KobalteSelect> - ) -} diff --git a/packages/app/src/components/session-list.tsx b/packages/app/src/components/session-list.tsx deleted file mode 100644 index e0819780d..000000000 --- a/packages/app/src/components/session-list.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useSync, useLocal } from "@/context" -import { Button, Tooltip } from "@/ui" -import { VList } from "virtua/solid" - -export default function SessionList() { - const sync = useSync() - const local = useLocal() - - return ( - <VList data={sync.data.session} class="p-2"> - {(session) => ( - <Tooltip placement="right" value={session.title} class="w-full min-w-0"> - <Button - size="sm" - variant="ghost" - classList={{ - "w-full min-w-0 py-1 text-left truncate justify-start text-text-muted text-xs": true, - "text-text!": local.session.active()?.id === session.id, - }} - onClick={() => local.session.setActive(session.id)} - > - <span class="truncate">{session.title}</span> - </Button> - </Tooltip> - )} - </VList> - ) -} diff --git a/packages/app/src/components/session-timeline.tsx b/packages/app/src/components/session-timeline.tsx deleted file mode 100644 index 07d93031e..000000000 --- a/packages/app/src/components/session-timeline.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import { useLocal, useSync } from "@/context" -import { Collapsible, Icon } from "@/ui" -import type { Part, ToolPart } from "@opencode-ai/sdk" -import { DateTime } from "luxon" -import { - createSignal, - onMount, - For, - Match, - splitProps, - Switch, - type ComponentProps, - type ParentProps, - createEffect, - createMemo, - Show, -} from "solid-js" -import { getFilename } from "@/utils" -import { Markdown } from "./markdown" -import { Code } from "./code" -import { createElementSize } from "@solid-primitives/resize-observer" -import { createScrollPosition } from "@solid-primitives/scroll" - -function Part(props: ParentProps & ComponentProps<"div">) { - const [local, others] = splitProps(props, ["class", "classList", "children"]) - return ( - <div - classList={{ - ...(local.classList ?? {}), - "h-6 flex items-center": true, - [local.class ?? ""]: !!local.class, - }} - {...others} - > - <p class="text-xs leading-4 text-left text-text-muted/60 font-medium">{local.children}</p> - </div> - ) -} - -function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) { - return ( - <Collapsible {...props}> - <Collapsible.Trigger class="peer/collapsible"> - <Part>{props.title}</Part> - </Collapsible.Trigger> - <Collapsible.Content> - <p class="flex-auto py-1 text-xs min-w-0 text-pretty"> - <span class="text-text-muted/60 break-words">{props.children}</span> - </p> - </Collapsible.Content> - </Collapsible> - ) -} - -function ReadToolPart(props: { part: ToolPart }) { - const sync = useSync() - const local = useLocal() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Reading file...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => { - const path = state().input["filePath"] as string - return ( - <Part class="cursor-pointer" onClick={() => local.file.open(path)}> - <span class="text-text-muted">Read</span> {getFilename(path)} - </Part> - ) - }} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <div> - <Part> - <span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)} - </Part> - <div class="text-error">{sync.sanitize(state().error)}</div> - </div> - )} - </Match> - </Switch> - ) -} - -function EditToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Preparing edit...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => ( - <CollapsiblePart - defaultOpen - title={ - <> - <span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)} - </> - } - > - <Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} /> - </CollapsiblePart> - )} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <CollapsiblePart - title={ - <> - <span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)} - </> - } - > - <div class="text-error">{sync.sanitize(state().error)}</div> - </CollapsiblePart> - )} - </Match> - </Switch> - ) -} - -function WriteToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Preparing write...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => ( - <CollapsiblePart - title={ - <> - <span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)} - </> - } - > - <div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div> - </CollapsiblePart> - )} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <div> - <Part> - <span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)} - </Part> - <div class="text-error">{sync.sanitize(state().error)}</div> - </div> - )} - </Match> - </Switch> - ) -} - -function BashToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - <Switch> - <Match when={props.part.state.status === "pending"}> - <Part>Writing shell command...</Part> - </Match> - <Match when={props.part.state.status === "completed" && props.part.state}> - {(state) => ( - <CollapsiblePart - defaultOpen - title={ - <> - <span class="text-text-muted">Run command:</span> {state().input["command"]} - </> - } - > - <Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} /> - </CollapsiblePart> - )} - </Match> - <Match when={props.part.state.status === "error" && props.part.state}> - {(state) => ( - <CollapsiblePart - title={ - <> - <span class="text-text-muted">Shell</span> {state().input["command"]} - </> - } - > - <div class="text-error">{sync.sanitize(state().error)}</div> - </CollapsiblePart> - )} - </Match> - </Switch> - ) -} - -function ToolPart(props: { part: ToolPart }) { - // read - // edit - // write - // bash - // ls - // glob - // grep - // todowrite - // todoread - // webfetch - // websearch - // patch - // task - return ( - <div class="min-w-0 flex-auto text-xs"> - <Switch - fallback={ - <span> - {props.part.type}:{props.part.tool} - </span> - } - > - <Match when={props.part.tool === "read"}> - <ReadToolPart part={props.part} /> - </Match> - <Match when={props.part.tool === "edit"}> - <EditToolPart part={props.part} /> - </Match> - <Match when={props.part.tool === "write"}> - <WriteToolPart part={props.part} /> - </Match> - <Match when={props.part.tool === "bash"}> - <BashToolPart part={props.part} /> - </Match> - </Switch> - </div> - ) -} - -export default function SessionTimeline(props: { session: string; class?: string }) { - const sync = useSync() - const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined) - const [root, setRoot] = createSignal<HTMLDivElement | undefined>(undefined) - const [tail, setTail] = createSignal(true) - const size = createElementSize(root) - const scroll = createScrollPosition(scrollElement) - - onMount(() => sync.session.sync(props.session)) - const session = createMemo(() => sync.session.get(props.session)) - const messages = createMemo(() => sync.data.message[props.session] ?? []) - const working = createMemo(() => { - const last = messages()[messages().length - 1] - if (!last) return false - if (last.role === "user") return true - return !last.time.completed - }) - - const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => { - let p = el?.parentElement - while (p && p !== document.body) { - const s = getComputedStyle(p) - if (s.overflowY === "auto" || s.overflowY === "scroll") return p - p = p.parentElement - } - return undefined - } - - createEffect(() => { - if (!root()) return - setScrollElement(getScrollParent(root()!)) - }) - - const scrollToBottom = () => { - const element = scrollElement() - if (!element) return - element.scrollTop = element.scrollHeight - } - - createEffect(() => { - size.height - if (tail()) scrollToBottom() - }) - - createEffect(() => { - if (working()) { - setTail(true) - scrollToBottom() - } - }) - - let lastScrollY = 0 - createEffect(() => { - if (scroll.y < lastScrollY) { - setTail(false) - } - lastScrollY = scroll.y - }) - - const valid = (part: Part) => { - if (!part) return false - switch (part.type) { - case "step-start": - case "step-finish": - case "file": - case "patch": - return false - case "text": - return !part.synthetic - case "reasoning": - return part.text.trim() - default: - return true - } - } - - const duration = (part: Part) => { - switch (part.type) { - default: - if ( - "time" in part && - part.time && - "start" in part.time && - part.time.start && - "end" in part.time && - part.time.end - ) { - const start = DateTime.fromMillis(part.time.start) - const end = DateTime.fromMillis(part.time.end) - return end.diff(start).toFormat("s") - } - return "" - } - } - - return ( - <div - ref={setRoot} - classList={{ - "p-4 select-text flex flex-col gap-y-1": true, - [props.class ?? ""]: !!props.class, - }} - > - <ul role="list" class="flex flex-col gap-1"> - <For each={messages()}> - {(message) => ( - <For each={sync.data.part[message.id]?.filter(valid)}> - {(part) => ( - <li class="group/li"> - <Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}> - <Match when={part.type === "text" && part}> - {(part) => ( - <Switch> - <Match when={message.role === "user"}> - <div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0"> - <p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel"> - <span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span> - </p> - <p class="text-xs text-text-muted"> - {DateTime.fromMillis(message.time.created).toRelative()} ·{" "} - {sync.data.config.username ?? "user"} - </p> - </div> - </Match> - <Match when={message.role === "assistant"}> - <Markdown text={sync.sanitize(part().text)} class="text-text mt-1" /> - </Match> - </Switch> - )} - </Match> - <Match when={part.type === "reasoning" && part}> - {(part) => ( - <CollapsiblePart - title={ - <Switch fallback={<span class="text-text-muted">Thinking</span>}> - <Match when={part().time.end}> - <span class="text-text-muted">Thought</span> for {duration(part())}s - </Match> - </Switch> - } - > - <Markdown text={part().text} /> - </CollapsiblePart> - )} - </Match> - <Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match> - </Switch> - </li> - )} - </For> - )} - </For> - </ul> - <Show when={false}> - <Collapsible defaultOpen={false}> - <Collapsible.Trigger> - <div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted"> - <Icon name="file-code" size={16} /> - <span>Raw Session Data</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content class="mt-5"> - <ul role="list" class="space-y-2"> - <li> - <Collapsible> - <Collapsible.Trigger> - <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" size={16} /> - <span>session</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Code path="session.json" code={JSON.stringify(session(), null, 2)} /> - </Collapsible.Content> - </Collapsible> - </li> - <For each={messages()}> - {(message) => ( - <> - <li> - <Collapsible> - <Collapsible.Trigger> - <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" size={16} /> - <span>{message.role === "user" ? "user" : "assistant"}</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} /> - </Collapsible.Content> - </Collapsible> - </li> - <For each={sync.data.part[message.id]}> - {(part) => ( - <li> - <Collapsible> - <Collapsible.Trigger> - <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" size={16} /> - <span>{part.type}</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} /> - </Collapsible.Content> - </Collapsible> - </li> - )} - </For> - </> - )} - </For> - </ul> - </Collapsible.Content> - </Collapsible> - </Show> - </div> - ) -} diff --git a/packages/app/src/components/sidebar-nav.tsx b/packages/app/src/components/sidebar-nav.tsx deleted file mode 100644 index 24750bdba..000000000 --- a/packages/app/src/components/sidebar-nav.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { For } from "solid-js" -import { Icon, Link, Logo, Tooltip } from "@/ui" -import { useLocation } from "@solidjs/router" - -const navigation = [ - { name: "Sessions", href: "/sessions", icon: "dashboard" as const }, - { name: "Commands", href: "/commands", icon: "slash" as const }, - { name: "Agents", href: "/agents", icon: "bolt" as const }, - { name: "Providers", href: "/providers", icon: "cloud" as const }, - { name: "Tools (MCP)", href: "/tools", icon: "hammer" as const }, - { name: "LSP", href: "/lsp", icon: "code" as const }, - { name: "Settings", href: "/settings", icon: "settings" as const }, -] - -export default function SidebarNav() { - const location = useLocation() - return ( - <div class="hidden md:fixed md:inset-y-0 md:left-0 md:z-50 md:block md:w-16 md:overflow-y-auto md:bg-background-panel md:pb-4"> - <div class="flex h-16 shrink-0 items-center justify-center"> - <Logo variant="mark" size={28} /> - </div> - <nav class="mt-5"> - <ul role="list" class="flex flex-col items-center space-y-1"> - <For each={navigation}> - {(item) => ( - <li> - <Tooltip placement="right" value={item.name}> - <Link - href={item.href} - classList={{ - "bg-background-element text-text": location.pathname.startsWith(item.href), - "text-text-muted hover:bg-background-element hover:text-text": location.pathname !== item.href, - "flex gap-x-3 rounded-md p-3 text-sm font-semibold": true, - "focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-border-active": true, - }} - > - <Icon name={item.icon} size={20} /> - <span class="sr-only">{item.name}</span> - </Link> - </Tooltip> - </li> - )} - </For> - </ul> - </nav> - </div> - ) -} |
