fix(app): regressions

This commit is contained in:
Adam
2026-02-10 10:12:17 -06:00
parent 3929f0b5bd
commit 70c794e913
13 changed files with 101 additions and 73 deletions

View File

@@ -1,4 +1,5 @@
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import { Collapsible } from "@opencode-ai/ui/collapsible" import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -20,11 +21,7 @@ import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@opencode-ai/sdk/v2"
function pathToFileUrl(filepath: string): string { function pathToFileUrl(filepath: string): string {
const encodedPath = filepath return `file://${encodeFilePath(filepath)}`
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
return `file://${encodedPath}`
} }
type Kind = "add" | "del" | "mix" type Kind = "add" | "del" | "mix"
@@ -223,12 +220,14 @@ export default function FileTree(props: {
seen.add(item) seen.add(item)
} }
return out.toSorted((a, b) => { out.sort((a, b) => {
if (a.type !== b.type) { if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1 return a.type === "directory" ? -1 : 1
} }
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
return out
}) })
const Node = ( const Node = (

View File

@@ -112,7 +112,7 @@ describe("buildRequestParts", () => {
// Special chars should be encoded // Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt") expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded // Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/) expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
} }
}) })
@@ -210,7 +210,7 @@ describe("buildRequestParts", () => {
if (filePart?.type === "file") { if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory // Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow() expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D%3A/other/project/file.ts") expect(filePart.url).toContain("/D:/other/project/file.ts")
} }
}) })

View File

@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
@@ -27,23 +28,11 @@ type BuildRequestPartsInput = {
sessionDirectory: string sessionDirectory: string
} }
const absolute = (directory: string, path: string) => const absolute = (directory: string, path: string) => {
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
const encodeFilePath = (filepath: string): string => { if (path.startsWith("\\\\") || path.startsWith("//")) return path
// Normalize Windows paths: convert backslashes to forward slashes return `${directory.replace(/[\\/]+$/, "")}/${path}`
let normalized = filepath.replace(/\\/g, "/")
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
if (/^[A-Za-z]:/.test(normalized)) {
normalized = "/" + normalized
}
// Encode each path segment (preserving forward slashes as path separators)
return normalized
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
} }
const fileQuery = (selection: FileSelection | undefined) => const fileQuery = (selection: FileSelection | undefined) =>

View File

@@ -108,7 +108,7 @@ describe("encodeFilePath", () => {
const url = new URL(fileUrl) const url = new URL(fileUrl)
expect(url.protocol).toBe("file:") expect(url.protocol).toBe("file:")
expect(url.pathname).toContain("README.bs.md") expect(url.pathname).toContain("README.bs.md")
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md") expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
}) })
test("should handle mixed separator path (Windows + Unix)", () => { test("should handle mixed separator path (Windows + Unix)", () => {
@@ -118,7 +118,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}` const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow() expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md") expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
}) })
test("should handle Windows path with spaces", () => { test("should handle Windows path with spaces", () => {
@@ -146,7 +146,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}` const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow() expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C%3A/") expect(result).toBe("/C:/")
}) })
test("should handle Windows relative path with backslashes", () => { test("should handle Windows relative path with backslashes", () => {
@@ -177,7 +177,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}` const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow() expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/c%3A/users/test/file.txt") expect(result).toBe("/c:/users/test/file.txt")
}) })
}) })
@@ -193,7 +193,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(windowsPath) const result = encodeFilePath(windowsPath)
// Should convert to forward slashes and add leading / // Should convert to forward slashes and add leading /
expect(result).not.toContain("\\") expect(result).not.toContain("\\")
expect(result).toMatch(/^\/[A-Za-z]%3A\//) expect(result).toMatch(/^\/[A-Za-z]:\//)
}) })
test("should handle relative paths the same on all platforms", () => { test("should handle relative paths the same on all platforms", () => {
@@ -237,7 +237,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(alreadyNormalized) const result = encodeFilePath(alreadyNormalized)
// Should not add another leading slash // Should not add another leading slash
expect(result).toBe("/D%3A/path/file.txt") expect(result).toBe("/D:/path/file.txt")
expect(result).not.toContain("//D") expect(result).not.toContain("//D")
}) })
@@ -246,7 +246,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(justDrive) const result = encodeFilePath(justDrive)
const fileUrl = `file://${result}` const fileUrl = `file://${result}`
expect(result).toBe("/D%3A") expect(result).toBe("/D:")
expect(() => new URL(fileUrl)).not.toThrow() expect(() => new URL(fileUrl)).not.toThrow()
}) })
@@ -256,7 +256,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}` const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow() expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C%3A/Users/test/") expect(result).toBe("/C:/Users/test/")
}) })
test("should handle very long paths", () => { test("should handle very long paths", () => {

View File

@@ -90,9 +90,14 @@ export function encodeFilePath(filepath: string): string {
} }
// Encode each path segment (preserving forward slashes as path separators) // Encode each path segment (preserving forward slashes as path separators)
// Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers
// can reliably detect drives.
return normalized return normalized
.split("/") .split("/")
.map((segment) => encodeURIComponent(segment)) .map((segment, index) => {
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
return encodeURIComponent(segment)
})
.join("/") .join("/")
} }

View File

@@ -25,7 +25,8 @@ export default function Home() {
const homedir = createMemo(() => sync.data.path.home) const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => { const recent = createMemo(() => {
return sync.data.project return sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) .slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5) .slice(0, 5)
}) })

View File

@@ -1938,7 +1938,7 @@ export default function Layout(props: ParentProps) {
direction="horizontal" direction="horizontal"
size={layout.sidebar.width()} size={layout.sidebar.width()}
min={244} min={244}
max={window.innerWidth * 0.3 + 64} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244} collapseThreshold={244}
onResize={layout.sidebar.resize} onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close} onCollapse={layout.sidebar.close}

View File

@@ -26,7 +26,7 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
export const childMapByParent = (sessions: Session[]) => { export const childMapByParent = (sessions: Session[]) => {
const map = new Map<string, string[]>() const map = new Map<string, string[]>()

View File

@@ -118,7 +118,7 @@ export const SortableWorkspace = (props: {
const touch = createMediaQuery("(hover: none)") const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
const loadMore = async () => { const loadMore = async () => {
setWorkspaceStore("limit", (limit) => limit + 5) setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.directory) await globalSync.project.loadSessions(props.directory)
} }
@@ -368,7 +368,7 @@ export const LocalWorkspace = (props: {
const loading = createMemo(() => !booted() && sessions().length === 0) const loading = createMemo(() => !booted() && sessions().length === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
const loadMore = async () => { const loadMore = async () => {
workspace().setStore("limit", (limit) => limit + 5) workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree) await globalSync.project.loadSessions(props.project.worktree)
} }

View File

@@ -1683,7 +1683,7 @@ export default function Page() {
direction="horizontal" direction="horizontal"
size={layout.session.width()} size={layout.session.width()}
min={450} min={450}
max={window.innerWidth * 0.45} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
onResize={layout.session.resize} onResize={layout.session.resize}
/> />
</Show> </Show>

View File

@@ -228,6 +228,7 @@ export const createScrollSpy = (input: Input) => {
node.delete(key) node.delete(key)
visible.delete(key) visible.delete(key)
dirty = true dirty = true
schedule()
} }
const markDirty = () => { const markDirty = () => {

View File

@@ -41,7 +41,7 @@ export function TerminalPanel(props: {
direction="vertical" direction="vertical"
size={props.height} size={props.height}
min={100} min={100}
max={window.innerHeight * 0.6} max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50} collapseThreshold={50}
onResize={props.resize} onResize={props.resize}
onCollapse={props.close} onCollapse={props.close}

View File

@@ -365,48 +365,81 @@ export const useSessionCommands = (input: {
return [ return [
{ {
id: "session.share", id: "session.share",
title: input.info()?.share?.url ? "Copy share link" : input.language.t("command.session.share"), title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
description: input.info()?.share?.url description: input.info()?.share?.url
? "Copy share URL to clipboard" ? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"), : input.language.t("command.session.share.description"),
category: input.language.t("command.category.session"), category: input.language.t("command.category.session"),
slash: "share", slash: "share",
disabled: !input.params.id, disabled: !input.params.id,
onSelect: async () => { onSelect: async () => {
if (!input.params.id) return if (!input.params.id) return
const copy = (url: string, existing: boolean) =>
navigator.clipboard const write = (value: string) => {
.writeText(url) const body = typeof document === "undefined" ? undefined : document.body
.then(() => if (body) {
showToast({ const textarea = document.createElement("textarea")
title: existing textarea.value = value
? input.language.t("session.share.copy.copied") textarea.setAttribute("readonly", "")
: input.language.t("toast.session.share.success.title"), textarea.style.position = "fixed"
description: input.language.t("toast.session.share.success.description"), textarea.style.opacity = "0"
variant: "success", textarea.style.pointerEvents = "none"
}), body.appendChild(textarea)
) textarea.select()
.catch(() => const copied = document.execCommand("copy")
showToast({ body.removeChild(textarea)
title: input.language.t("toast.session.share.copyFailed.title"), if (copied) return Promise.resolve(true)
variant: "error", }
}),
) const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
const url = input.info()?.share?.url if (!clipboard?.writeText) return Promise.resolve(false)
if (url) { return clipboard.writeText(value).then(
await copy(url, true) () => true,
() => false,
)
}
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
return return
} }
await input.sdk.client.session
const url = await input.sdk.client.session
.share({ sessionID: input.params.id }) .share({ sessionID: input.params.id })
.then((res) => copy(res.data!.share!.url, false)) .then((res) => res.data?.share?.url)
.catch(() => .catch(() => undefined)
showToast({ if (!url) {
title: input.language.t("toast.session.share.failed.title"), showToast({
description: input.language.t("toast.session.share.failed.description"), title: input.language.t("toast.session.share.failed.title"),
variant: "error", description: input.language.t("toast.session.share.failed.description"),
}), variant: "error",
) })
return
}
await copy(url, false)
}, },
}, },
{ {