fix(app): regressions
This commit is contained in:
@@ -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 = (
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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[]>()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user