Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
Expand All @@ -23,6 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { resolveSessionPluginUI } from "@/utils/plugin-ui"
import { StatusPopover } from "../status-popover"

const OPEN_APPS = [
Expand Down Expand Up @@ -131,6 +133,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
export function SessionHeader() {
const layout = useLayout()
const command = useCommand()
const globalSDK = useGlobalSDK()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
Expand All @@ -139,6 +142,8 @@ export function SessionHeader() {
const { params, view } = useSessionLayout()

const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
Expand All @@ -151,6 +156,8 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const pluginUI = createMemo(() => resolveSessionPluginUI(sync.data.config, projectDirectory(), globalSDK.url))
const webButtons = createMemo(() => pluginUI().buttons)

const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
Expand Down Expand Up @@ -262,6 +269,11 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}

const openWebTab = (tab: string) => {
view().reviewPanel.open()
tabs().open(tab)
}

const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))

Expand Down Expand Up @@ -418,6 +430,18 @@ export function SessionHeader() {
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<For each={webButtons()}>
{(button) => (
<Button
variant="ghost"
class="hidden md:flex h-[24px] px-2 rounded-md border border-border-base bg-surface-panel text-text-strong"
onClick={() => openWebTab(button.tab)}
aria-label={`Open ${button.label}`}
>
<span class="text-12-regular">{button.label}</span>
</Button>
)}
</For>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
Expand Down
56 changes: 56 additions & 0 deletions packages/app/src/context/file.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const layout = useLayout()

const scope = createMemo(() => sdk.directory)
const encodedDirectory = createMemo(() => {
const value = sdk.directory
if (!value) return ""
const isNonASCII = /[^\x00-\x7F]/.test(value)
return isNonASCII ? encodeURIComponent(value) : value
})
const path = createPathHelpers(scope)
const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)

Expand Down Expand Up @@ -200,6 +206,55 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
() => [],
)

const write = async (input: { path: string; content: string }) => {
const file = path.normalize(input.path)
if (!file) throw new Error("Invalid path")

const url = new URL("/file/content", sdk.url)
url.searchParams.set("path", file)

const response = await fetch(url, {
method: "PUT",
headers: {
"content-type": "application/json",
"x-opencode-directory": encodedDirectory(),
},
body: JSON.stringify({ content: input.content }),
})

if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Failed to write ${file}`)
}

const result = (await response.json().catch(() => ({ path: file, hash: "" }))) as {
path: string
hash: string
}

const next = {
type: "text",
content: input.content,
} as const

setStore(
"file",
file,
produce((draft) => {
draft.path = file
draft.name = getFilename(file)
draft.loaded = true
draft.loading = false
draft.error = undefined
draft.content = next
}),
)

touchFileContent(file, approxBytes(next))
evictContent(new Set([file]))
return result
}

const stop = sdk.event.listen((e) => {
invalidateFromWatcher(e.details, {
normalize: path.normalize,
Expand Down Expand Up @@ -267,6 +322,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
},
get,
load,
write,
scrollTop,
scrollLeft,
setScrollTop,
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { useGlobalSDK } from "@/context/global-sdk"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
Expand Down Expand Up @@ -58,6 +59,7 @@ import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { Persist, persisted } from "@/utils/persist"
import { resolveSessionPluginUI } from "@/utils/plugin-ui"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
Expand Down Expand Up @@ -313,6 +315,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
}

export default function Page() {
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const layout = useLayout()
const local = useLocal()
Expand Down Expand Up @@ -429,12 +432,16 @@ export default function Page() {
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const reviewTab = createMemo(() => isDesktop())
const pluginUI = createMemo(() => resolveSessionPluginUI(sync.data.config, sdk.directory, globalSDK.url))
const webTabs = createMemo(() => pluginUI().tabs)
const webSet = createMemo(() => new Set(webTabs().map((tab) => tab.tab)))
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
isExtraTab: (tab) => webSet().has(tab),
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
Expand Down Expand Up @@ -1865,6 +1872,8 @@ export default function Page() {
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
webTabs={webTabs}
directory={sdk.directory}
/>
</div>

Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/pages/session/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ type TabsInput = {
normalizeTab: (tab: string) => string
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
isExtraTab?: (tab: string) => boolean
}

export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`

export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const isExtra = input.isExtraTab ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openedTabs = createMemo(
() => {
Expand All @@ -31,6 +33,8 @@ export const createSessionTabs = (input: TabsInput) => {
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review") return []
if (isExtra(tab)) return []
if (!input.pathFromTab(tab)) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
seen.add(value)
Expand All @@ -44,10 +48,16 @@ export const createSessionTabs = (input: TabsInput) => {
const active = input.tabs().active()
if (active === "context") return active
if (active === "review" && review()) return active
if (active && isExtra(active)) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)

const first = openedTabs()[0]
if (first) return first
const extra = input
.tabs()
.all()
.find((tab) => isExtra(tab))
if (extra) return extra
if (contextOpen()) return "context"
if (review() && hasReview()) return "review"
return "empty"
Expand Down
Loading
Loading