mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-30 14:14:20 +01:00
feat: add desktop/web app package (#2606)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
1358
packages/app/src/components/code.tsx
Normal file
1358
packages/app/src/components/code.tsx
Normal file
File diff suppressed because it is too large
Load Diff
85
packages/app/src/components/file-tree.tsx
Normal file
85
packages/app/src/components/file-tree.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
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-panel cursor-pointer": true,
|
||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${level * 10}px`}
|
||||
{...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
|
||||
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>
|
||||
)
|
||||
}
|
||||
601
packages/app/src/components/markdown.tsx
Normal file
601
packages/app/src/components/markdown.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
import { transformerNotationDiff } from "@shikijs/transformers"
|
||||
import { marked } from "marked"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { codeToHtml } from "shiki"
|
||||
import { createResource } from "solid-js"
|
||||
|
||||
const markedWithShiki = marked.use(
|
||||
markedShiki({
|
||||
highlight(code, lang) {
|
||||
return codeToHtml(code, {
|
||||
// structure: "inline",
|
||||
lang: lang || "text",
|
||||
tabindex: false,
|
||||
theme: {
|
||||
colors: {
|
||||
"actionBar.toggledBackground": "var(--theme-background-element)",
|
||||
"activityBarBadge.background": "var(--theme-accent)",
|
||||
"checkbox.border": "var(--theme-border)",
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "var(--theme-text)",
|
||||
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"editor.selectionHighlightBackground": "var(--theme-border-active)",
|
||||
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
|
||||
"editorIndentGuide.background1": "var(--theme-border-subtle)",
|
||||
"input.placeholderForeground": "var(--theme-text-muted)",
|
||||
"list.activeSelectionIconForeground": "var(--theme-text)",
|
||||
"list.dropBackground": "var(--theme-background-element)",
|
||||
"menu.background": "var(--theme-background-panel)",
|
||||
"menu.border": "var(--theme-border)",
|
||||
"menu.foreground": "var(--theme-text)",
|
||||
"menu.selectionBackground": "var(--theme-primary)",
|
||||
"menu.separatorBackground": "var(--theme-border)",
|
||||
"ports.iconRunningProcessForeground": "var(--theme-success)",
|
||||
"sideBarSectionHeader.background": "transparent",
|
||||
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
|
||||
"sideBarTitle.foreground": "var(--theme-text-muted)",
|
||||
"statusBarItem.remoteBackground": "var(--theme-success)",
|
||||
"statusBarItem.remoteForeground": "var(--theme-text)",
|
||||
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
|
||||
"tab.selectedBackground": "var(--theme-background-element)",
|
||||
"tab.selectedForeground": "var(--theme-text-muted)",
|
||||
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"widget.border": "var(--theme-border)",
|
||||
},
|
||||
displayName: "opencode",
|
||||
name: "opencode",
|
||||
semanticHighlighting: true,
|
||||
semanticTokenColors: {
|
||||
customLiteral: "var(--theme-syntax-function)",
|
||||
newOperator: "var(--theme-syntax-operator)",
|
||||
numberLiteral: "var(--theme-syntax-number)",
|
||||
stringLiteral: "var(--theme-syntax-string)",
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: [
|
||||
"meta.embedded",
|
||||
"source.groovy.embedded",
|
||||
"string meta.image.inline.markdown",
|
||||
"variable.legacy.builtin.python",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "emphasis",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "strong",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "header",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "comment",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-comment)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"variable.other.enummember",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag.css", "entity.name.tag.less"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.other.attribute-name.class.css",
|
||||
"source.css entity.other.attribute-name.class",
|
||||
"entity.other.attribute-name.id.css",
|
||||
"entity.other.attribute-name.parent-selector.css",
|
||||
"entity.other.attribute-name.parent.less",
|
||||
"source.css entity.other.attribute-name.pseudo-class",
|
||||
"entity.other.attribute-name.pseudo-element.css",
|
||||
"source.css.less entity.other.attribute-name.id",
|
||||
"entity.other.attribute-name.scss",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: {
|
||||
foreground: "var(--theme-error)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.underline",
|
||||
settings: {
|
||||
fontStyle: "underline",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-strong)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.italic",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.strikethrough",
|
||||
settings: {
|
||||
fontStyle: "strikethrough",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-added)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-removed)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-context)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.quote.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-block-quote)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.list.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-list-enumeration)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-code)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-punctuation)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.string",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.numeric",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.structure.dictionary.key.python",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.diff.header",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-hunk-header)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.value",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.template.expression"],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.vendored.property-name",
|
||||
"support.type.property-name",
|
||||
"source.css variable",
|
||||
"source.coffee.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.control",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.alignof",
|
||||
"keyword.operator.typeid",
|
||||
"keyword.operator.alignas",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.other.unit",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "support.function.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.sha.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"storage.modifier.import.java",
|
||||
"variable.language.wildcard.java",
|
||||
"storage.modifier.package.java",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.name.function",
|
||||
"support.function",
|
||||
"support.constant.handlebars",
|
||||
"source.powershell variable.other.member",
|
||||
"entity.name.operator.custom-literal",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-function)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.other.attribute",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"storage.type.numeric.go",
|
||||
"storage.type.byte.go",
|
||||
"storage.type.boolean.go",
|
||||
"storage.type.string.go",
|
||||
"storage.type.uintptr.go",
|
||||
"storage.type.error.go",
|
||||
"storage.type.rune.go",
|
||||
"storage.type.cs",
|
||||
"storage.type.generic.cs",
|
||||
"storage.type.modifier.cs",
|
||||
"storage.type.variable.cs",
|
||||
"storage.type.annotation.java",
|
||||
"storage.type.generic.java",
|
||||
"storage.type.java",
|
||||
"storage.type.object.array.java",
|
||||
"storage.type.primitive.array.java",
|
||||
"storage.type.primitive.java",
|
||||
"storage.type.token.java",
|
||||
"storage.type.groovy",
|
||||
"storage.type.annotation.groovy",
|
||||
"storage.type.parameters.groovy",
|
||||
"storage.type.generic.groovy",
|
||||
"storage.type.object.array.groovy",
|
||||
"storage.type.primitive.array.groovy",
|
||||
"storage.type.primitive.groovy",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
"entity.other.inherited-class",
|
||||
"punctuation.separator.namespace.ruby",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"source.cpp keyword.operator.new",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.directive.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.object-literal.key"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.constant.property-value",
|
||||
"support.constant.font-name",
|
||||
"support.constant.media-type",
|
||||
"support.constant.media",
|
||||
"constant.other.color.rgb-value",
|
||||
"constant.other.rgb-value",
|
||||
"support.constant.color",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.group.regexp",
|
||||
"punctuation.definition.group.assertion.regexp",
|
||||
"punctuation.definition.character-class.regexp",
|
||||
"punctuation.character.set.begin.regexp",
|
||||
"punctuation.character.set.end.regexp",
|
||||
"keyword.operator.negation.regexp",
|
||||
"support.other.parenthesis.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.character.character-class.regexp",
|
||||
"constant.other.character-class.set.regexp",
|
||||
"constant.other.character-class.regexp",
|
||||
"constant.character.set.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator.quantifier.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["constant.character", "constant.other.option"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.label",
|
||||
settings: {
|
||||
foreground: "var(--theme-text-muted)",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "dark",
|
||||
},
|
||||
transformers: [transformerNotationDiff()],
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
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 default function Markdown(props: { text: string; class?: string }) {
|
||||
const [html] = createResource(
|
||||
() => strip(props.text),
|
||||
async (markdown) => {
|
||||
return markedWithShiki.parse(markdown)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<div
|
||||
class={`min-w-0 max-w-full text-xs overflow-auto no-scrollbar prose ${props.class ?? ""}`}
|
||||
innerHTML={html()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
28
packages/app/src/components/session-list.tsx
Normal file
28
packages/app/src/components/session-list.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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 no-scrollbar">
|
||||
{(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>
|
||||
)
|
||||
}
|
||||
369
packages/app/src/components/session-timeline.tsx
Normal file
369
packages/app/src/components/session-timeline.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useLocal, useSync } from "@/context"
|
||||
import { Collapsible, Icon, type IconProps } 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,
|
||||
} 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 TimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"relative flex flex-none self-start items-center justify-center bg-background h-6 w-6": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<Icon name={props.name} class="text-text/40" size={18} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleTimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
||||
return (
|
||||
<>
|
||||
<TimelineIcon
|
||||
name={props.name}
|
||||
class={`group-hover/li:hidden group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
||||
/>
|
||||
<TimelineIcon
|
||||
name="chevron-right"
|
||||
class={`hidden group-hover/li:flex group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
||||
/>
|
||||
<TimelineIcon name="chevron-down" class={`hidden group-has-[[data-expanded]]/li:flex ${props.class ?? ""}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolIcon(props: { part: ToolPart }) {
|
||||
return (
|
||||
<Switch fallback={<TimelineIcon name="hammer" />}>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<TimelineIcon name="file" />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<CollapsibleTimelineIcon name="pencil" />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<CollapsibleTimelineIcon name="file-plus" />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
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 local = useLocal()
|
||||
return (
|
||||
<Switch>
|
||||
<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>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function EditToolPart(props: { part: ToolPart }) {
|
||||
return (
|
||||
<Switch>
|
||||
<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}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function WriteToolPart(props: { part: ToolPart }) {
|
||||
return (
|
||||
<Switch>
|
||||
<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>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPart(props: { part: ToolPart }) {
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="flex-auto min-w-0 text-xs">
|
||||
{props.part.type}:{props.part.tool}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<ReadToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<EditToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<WriteToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
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 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-8": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<ul role="list" class="space-y-2">
|
||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||
{(part) => (
|
||||
<li classList={{ "relative group/li flex gap-x-4 min-w-0 w-full": true }}>
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 left-0 flex w-6 justify-center": true,
|
||||
"last:h-10 not-last:-bottom-10": true,
|
||||
}}
|
||||
>
|
||||
<div class="w-px bg-border-subtle" />
|
||||
</div>
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="m-0.5 relative flex size-5 flex-none items-center justify-center bg-background">
|
||||
<div class="size-1 rounded-full bg-text/10 ring ring-text/20" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={part.type === "text"}>
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<TimelineIcon name="avatar-square" />
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<TimelineIcon name="sparkles" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning"}>
|
||||
<CollapsibleTimelineIcon name="brain" />
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>{(part) => <ToolIcon part={part()} />}</Match>
|
||||
</Switch>
|
||||
<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">
|
||||
<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">12:07pm · adam</p>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<Markdown text={part().text} class="text-text" />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning" && part}>
|
||||
{(part) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Thought</span> for {duration(part())}s
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Markdown text={part().text} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
packages/app/src/components/sidebar-nav.tsx
Normal file
48
packages/app/src/components/sidebar-nav.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user