Browse Source

fix(app): regressions

Adam 2 weeks ago
parent
commit
70c794e913

+ 5 - 6
packages/app/src/components/file-tree.tsx

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

+ 2 - 2
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -112,7 +112,7 @@ describe("buildRequestParts", () => {
       // Special chars should be encoded
       expect(filePart.url).toContain("file%23name.txt")
       // 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") {
       // Should handle absolute path that differs from sessionDirectory
       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")
     }
   })
 

+ 6 - 17
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -1,6 +1,7 @@
 import { getFilename } from "@opencode-ai/util/path"
 import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
 import type { FileSelection } from "@/context/file"
+import { encodeFilePath } from "@/context/file/path"
 import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
 import { Identifier } from "@/utils/id"
 
@@ -27,23 +28,11 @@ type BuildRequestPartsInput = {
   sessionDirectory: string
 }
 
-const absolute = (directory: string, path: string) =>
-  path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
-
-const encodeFilePath = (filepath: string): string => {
-  // Normalize Windows paths: convert backslashes to forward slashes
-  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 absolute = (directory: string, path: string) => {
+  if (path.startsWith("/")) return path
+  if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
+  if (path.startsWith("\\\\") || path.startsWith("//")) return path
+  return `${directory.replace(/[\\/]+$/, "")}/${path}`
 }
 
 const fileQuery = (selection: FileSelection | undefined) =>

+ 8 - 8
packages/app/src/context/file/path.test.ts

@@ -108,7 +108,7 @@ describe("encodeFilePath", () => {
       const url = new URL(fileUrl)
       expect(url.protocol).toBe("file:")
       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)", () => {
@@ -118,7 +118,7 @@ describe("encodeFilePath", () => {
       const fileUrl = `file://${result}`
 
       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", () => {
@@ -146,7 +146,7 @@ describe("encodeFilePath", () => {
       const fileUrl = `file://${result}`
 
       expect(() => new URL(fileUrl)).not.toThrow()
-      expect(result).toBe("/C%3A/")
+      expect(result).toBe("/C:/")
     })
 
     test("should handle Windows relative path with backslashes", () => {
@@ -177,7 +177,7 @@ describe("encodeFilePath", () => {
       const fileUrl = `file://${result}`
 
       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)
       // Should convert to forward slashes and add leading /
       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", () => {
@@ -237,7 +237,7 @@ describe("encodeFilePath", () => {
       const result = encodeFilePath(alreadyNormalized)
 
       // 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")
     })
 
@@ -246,7 +246,7 @@ describe("encodeFilePath", () => {
       const result = encodeFilePath(justDrive)
       const fileUrl = `file://${result}`
 
-      expect(result).toBe("/D%3A")
+      expect(result).toBe("/D:")
       expect(() => new URL(fileUrl)).not.toThrow()
     })
 
@@ -256,7 +256,7 @@ describe("encodeFilePath", () => {
       const fileUrl = `file://${result}`
 
       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", () => {

+ 6 - 1
packages/app/src/context/file/path.ts

@@ -90,9 +90,14 @@ export function encodeFilePath(filepath: string): string {
   }
 
   // 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
     .split("/")
-    .map((segment) => encodeURIComponent(segment))
+    .map((segment, index) => {
+      if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
+      return encodeURIComponent(segment)
+    })
     .join("/")
 }
 

+ 2 - 1
packages/app/src/pages/home.tsx

@@ -25,7 +25,8 @@ export default function Home() {
   const homedir = createMemo(() => sync.data.path.home)
   const recent = createMemo(() => {
     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)
   })
 

+ 1 - 1
packages/app/src/pages/layout.tsx

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

+ 1 - 1
packages/app/src/pages/layout/helpers.ts

@@ -26,7 +26,7 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
   workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
 
 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[]) => {
   const map = new Map<string, string[]>()

+ 2 - 2
packages/app/src/pages/layout/sidebar-workspace.tsx

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

+ 1 - 1
packages/app/src/pages/session.tsx

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

+ 1 - 0
packages/app/src/pages/session/scroll-spy.ts

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

+ 1 - 1
packages/app/src/pages/session/terminal-panel.tsx

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

+ 65 - 32
packages/app/src/pages/session/use-session-commands.tsx

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