From 71e7cf6ff80ee8a0e7bccc0490c7e8e22e220711 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:09:07 +0200 Subject: [PATCH 01/63] Build native Android modules with the project NDK (#7403) * fix(android): align Unistyles NDK version * fix(android): use the project NDK for every native module The diff module still selected the Android Gradle default NDK, causing a second installation after Unistyles was aligned. Inherit the project toolchain and check all configured native projects. --------- Co-authored-by: dwyanewang <908280968@qq.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../paseo-diff-prototype/android/build.gradle | 1 + .../app/scripts/verify-android-ndk.gradle | 22 +++++++++++++++++++ patches/react-native-unistyles+3.2.4.patch | 12 ++++++++++ 3 files changed, 35 insertions(+) create mode 100644 packages/app/scripts/verify-android-ndk.gradle diff --git a/packages/app/modules/paseo-diff-prototype/android/build.gradle b/packages/app/modules/paseo-diff-prototype/android/build.gradle index f218c03ef8..3f717f4de4 100644 --- a/packages/app/modules/paseo-diff-prototype/android/build.gradle +++ b/packages/app/modules/paseo-diff-prototype/android/build.gradle @@ -12,6 +12,7 @@ useDefaultAndroidSdkVersions() def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile android { + ndkVersion rootProject.ext.ndkVersion namespace "sh.paseo.diffprototype" defaultConfig { externalNativeBuild { diff --git a/packages/app/scripts/verify-android-ndk.gradle b/packages/app/scripts/verify-android-ndk.gradle new file mode 100644 index 0000000000..a53a5406bb --- /dev/null +++ b/packages/app/scripts/verify-android-ndk.gradle @@ -0,0 +1,22 @@ +// After Expo prebuild, run from packages/app/android: +// ./gradlew --init-script ../scripts/verify-android-ndk.gradle verifyAndroidNdk +gradle.projectsEvaluated { + rootProject.tasks.register('verifyAndroidNdk') { + doLast { + def expected = rootProject.ext.ndkVersion + def nativeProjects = rootProject.subprojects.findAll { subproject -> + def android = subproject.extensions.findByName('android') + android != null && (android.externalNativeBuild.cmake.path != null || + android.externalNativeBuild.ndkBuild.path != null) + } + assert !nativeProjects.empty : 'No native Android modules found' + println "Project NDK: ${expected}" + def mismatches = nativeProjects.findAll { subproject -> + def actual = subproject.android.ndkVersion + println "${subproject.path} NDK: ${actual}" + actual != expected + } + assert mismatches.empty : "Native Android modules must use project NDK ${expected}: ${mismatches*.path}" + } + } +} diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 25324c6218..0195868340 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -317,3 +317,15 @@ index 39e0d9d..a21ba95 100644 this.stylesCache.clear() this.dependenciesMap.clear() this.disposeListenersMap.clear() +diff --git a/node_modules/react-native-unistyles/android/build.gradle b/node_modules/react-native-unistyles/android/build.gradle +index 195954b..c4ac28c 100644 +--- a/node_modules/react-native-unistyles/android/build.gradle ++++ b/node_modules/react-native-unistyles/android/build.gradle +@@ -38,6 +38,7 @@ if (isNewArchitectureEnabled()) { + + android { + compileSdkVersion safeExtGet('compileSdkVersion', 35) ++ ndkVersion safeExtGet('ndkVersion', '27.1.12297006') + namespace "com.unistyles" + + defaultConfig { From fc775b6db5e99ce4e0b1b3718250a023284557da Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:03:24 +0200 Subject: [PATCH 02/63] Load translations when their language is selected (#7410) * perf(app): load translations when their language is selected Eager locale imports evaluated and registered all nine translation bundles on every cold start. Load bundled locales through a synchronous i18next backend so language selection keeps its before-render contract and offline behavior. Co-authored-by: Ivan Malison * test(app): normalize translation entry paths across platforms --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Ivan Malison --- .../src/git/pull-request-panel/data.test.ts | 7 +- packages/app/src/i18n/i18next.startup.test.ts | 92 +++++++++++++++++++ packages/app/src/i18n/i18next.ts | 91 +++++++++++------- packages/app/vitest.config.ts | 19 ++++ 4 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/i18n/i18next.startup.test.ts diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 837433417e..1f72cb1d95 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { CheckoutPipelineJob, CheckoutPrStatusResponse, @@ -780,6 +780,11 @@ describe("formatAge", () => { }); }); +// Fixed translators do not select a language, so request their resources first. +beforeAll(async () => { + await i18n.loadLanguages("fr"); +}); + const english = i18n.getFixedT("en"); const french = i18n.getFixedT("fr"); diff --git a/packages/app/src/i18n/i18next.startup.test.ts b/packages/app/src/i18n/i18next.startup.test.ts new file mode 100644 index 0000000000..f439a5bb10 --- /dev/null +++ b/packages/app/src/i18n/i18next.startup.test.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { build } from "esbuild"; +import { beforeAll, describe, expect, it } from "vitest"; + +let bundle: string; + +// Exercise the bundled entry in fresh processes so the test runner's module cache +// cannot hide eager evaluation. The probe only records resource module execution. +beforeAll(async () => { + const result = await build({ + entryPoints: [path.join(import.meta.dirname, "i18next.ts")], + bundle: true, + platform: "node", + format: "cjs", + external: ["i18next", "react-i18next"], + write: false, + plugins: [ + { + name: "observe-translation-evaluation", + setup(builder) { + builder.onLoad( + { filter: /[/\\]i18n[/\\]resources[/\\][a-z]{2}(?:-[A-Z]{2})?\.ts$/ }, + async ({ path: file }) => ({ + contents: `globalThis.evaluatedLocales.push(${JSON.stringify(path.basename(file, ".ts"))});\n${await readFile(file, "utf8")}`, + loader: "ts", + }), + ); + }, + }, + ], + }); + bundle = result.outputFiles[0].text; +}); + +function runStartupProbe(actions = "") { + return JSON.parse( + execFileSync(process.execPath, ["-"], { + cwd: path.resolve(import.meta.dirname, "../.."), + encoding: "utf8", + input: `globalThis.evaluatedLocales = []; + (function () { ${bundle} })(); + const { i18n } = module.exports; + ${actions} + console.log(JSON.stringify({ + evaluated: globalThis.evaluatedLocales, + registered: Object.keys(i18n.store.data), + language: i18n.language, + cancel: i18n.t("common.actions.cancel"), + fallback: i18n.t("startupProbeOnlyInEnglish"), + }));`, + }), + ); +} + +describe("bundled translation startup", () => { + it("evaluates and registers only English before a language is selected", () => { + expect(runStartupProbe()).toMatchObject({ + evaluated: ["en"], + registered: ["en"], + language: "en", + cancel: "Cancel", + }); + }); + + it.each([ + ["ar", "يلغي"], + ["es", "Cancelar"], + ["fr", "Annuler"], + ["ja", "キャンセル"], + ["ko", "취소"], + ["pt-BR", "Cancelar"], + ["ru", "Отмена"], + ["zh-CN", "取消"], + ])("loads %s synchronously on selection and reuses it", (locale, cancel) => { + const result = runStartupProbe(` + i18n.addResource("en", "translation", "startupProbeOnlyInEnglish", "English fallback"); + i18n.changeLanguage(${JSON.stringify(locale)}); + if (i18n.language !== ${JSON.stringify(locale)}) throw new Error("Language selection became asynchronous"); + i18n.changeLanguage("en"); + i18n.changeLanguage(${JSON.stringify(locale)}); + `); + expect(result).toEqual({ + evaluated: ["en", locale], + registered: ["en", locale], + language: locale, + cancel, + fallback: "English fallback", + }); + }); +}); diff --git a/packages/app/src/i18n/i18next.ts b/packages/app/src/i18n/i18next.ts index 05ceeee6ee..ccbfb8da46 100644 --- a/packages/app/src/i18n/i18next.ts +++ b/packages/app/src/i18n/i18next.ts @@ -1,41 +1,70 @@ -import { createInstance } from "i18next"; +import { createInstance, type BackendModule } from "i18next"; import { initReactI18next } from "react-i18next"; import { observeI18nInit } from "./init"; -import { ar } from "./resources/ar"; import { en } from "./resources/en"; -import { es } from "./resources/es"; -import { fr } from "./resources/fr"; -import { ja } from "./resources/ja"; -import { ko } from "./resources/ko"; -import { ptBR } from "./resources/pt-BR"; -import { ru } from "./resources/ru"; -import { zhCN } from "./resources/zh-CN"; + +// Literal requires keep every locale bundled for offline use, while evaluating +// its resource module only when selected. Synchronous reads preserve the language +// provider's contract: translations are ready before it renders children. +const bundledTranslations: BackendModule = { + type: "backend", + init() {}, + read(language, _namespace, callback) { + switch (language) { + case "en": + callback(null, en); + return; + case "ar": + callback(null, require("./resources/ar").ar); + return; + case "es": + callback(null, require("./resources/es").es); + return; + case "fr": + callback(null, require("./resources/fr").fr); + return; + case "ja": + callback(null, require("./resources/ja").ja); + return; + case "ko": + callback(null, require("./resources/ko").ko); + return; + case "pt-BR": + callback(null, require("./resources/pt-BR").ptBR); + return; + case "ru": + callback(null, require("./resources/ru").ru); + return; + case "zh-CN": + callback(null, require("./resources/zh-CN").zhCN); + return; + default: + callback(null, undefined); + } + }, +}; const i18n = createInstance(); observeI18nInit( - i18n.use(initReactI18next).init({ - compatibilityJSON: "v4", - fallbackLng: "en", - lng: "en", - resources: { - ar: { translation: ar }, - en: { translation: en }, - es: { translation: es }, - fr: { translation: fr }, - ja: { translation: ja }, - ko: { translation: ko }, - "pt-BR": { translation: ptBR }, - ru: { translation: ru }, - "zh-CN": { translation: zhCN }, - }, - interpolation: { - escapeValue: false, - }, - react: { - useSuspense: false, - }, - }), + i18n + .use(bundledTranslations) + .use(initReactI18next) + .init({ + compatibilityJSON: "v4", + fallbackLng: "en", + lng: "en", + partialBundledLanguages: true, + resources: { + en: { translation: en }, + }, + interpolation: { + escapeValue: false, + }, + react: { + useSuspense: false, + }, + }), ); export { i18n }; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 7a45d4a771..6274691582 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -3,6 +3,7 @@ import { playwright } from "@vitest/browser-playwright"; import path from "path"; import fs from "fs"; import { transformSync } from "@babel/core"; +import { build } from "esbuild"; // Set this before Vite resolves dependencies and Vitest starts its workers. Object.assign(process.env, { NODE_ENV: "test" }); @@ -35,6 +36,24 @@ export default defineConfig({ if (result?.code) return { code: result.code, map: result.map }; }, }, + { + name: "bundle-i18n-resources", + // Metro resolves literal requires lazily. Vite's TS transform leaves them + // to Node, which cannot resolve the resource modules. Bundle this boundary + // so unit and browser consumers exercise the same deferred module loading. + async transform(_source, id) { + if (path.resolve(id) !== path.resolve(__dirname, "src/i18n/i18next.ts")) return; + const result = await build({ + entryPoints: [id], + bundle: true, + format: "esm", + platform: "neutral", + external: ["i18next", "react-i18next"], + write: false, + }); + return { code: result.outputFiles[0].text, map: null }; + }, + }, ], test: { environment: "node", From 90f3cab5850b9aed92c66eba0e3974f882527701 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:58:09 +0200 Subject: [PATCH 03/63] fix(nix): report the project license in package metadata (#7415) The Apache-2.0 relicensing left both Nix derivations declaring AGPL. Match their metadata to the project license and check both packages against the root manifest on every supported Nix system. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- flake.nix | 15 +++++++++++++++ nix/desktop-package.nix | 2 +- nix/package.nix | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 9f98752e7e..c9e3aa57d9 100644 --- a/flake.nix +++ b/flake.nix @@ -45,6 +45,21 @@ } ); + checks = forAllSystems ( + system: + let + pkgs = pkgsFor system; + packages = self.packages.${system}; + license = (builtins.fromJSON (builtins.readFile ./package.json)).license; + in + { + license-metadata = + assert packages.paseo.meta.license.spdxId == license; + assert packages.desktop.meta.license.spdxId == license; + pkgs.runCommand "paseo-license-metadata" { } "touch $out"; + } + ); + nixosModules.default = self.nixosModules.paseo; nixosModules.paseo = { pkgs, lib, ... }: diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index 3fd5f47093..00ed6cd926 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -257,7 +257,7 @@ buildNpmPackage { meta = { description = "Paseo desktop app (Electron wrapper)"; homepage = "https://github.com/getpaseo/paseo"; - license = lib.licenses.agpl3Plus; + license = lib.licenses.asl20; mainProgram = "paseo-desktop"; platforms = lib.platforms.linux ++ lib.platforms.darwin; }; diff --git a/nix/package.nix b/nix/package.nix index b032d88396..f291784ea2 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -148,7 +148,7 @@ buildNpmPackage rec { meta = { description = "Self-hosted daemon for Claude Code, Codex, and OpenCode"; homepage = "https://github.com/getpaseo/paseo"; - license = lib.licenses.agpl3Plus; + license = lib.licenses.asl20; mainProgram = "paseo"; platforms = lib.platforms.linux ++ lib.platforms.darwin; }; From 69ef741a73559b6fd00fc483aa1a331fac21c229 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:26:01 +0200 Subject: [PATCH 04/63] fix(app): pause native status rings in hidden panels (#7423) Retained panels keep their children mounted, so mount-only registration kept hidden rings subscribed to the shared UI clock. Detach inactive rings and retain a local phase so hidden animated styles also stop receiving updates while visible rings continue. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Pete Keen --- packages/app/e2e/mobile/status-ring/README.md | 45 ++++++++++++ .../app/e2e/mobile/status-ring/android.mjs | 71 +++++++++++++++++++ .../app/e2e/mobile/status-ring/native.tsx | 69 ++++++++++++++++++ .../app/src/components/status-ring/clock.ts | 30 ++++++-- 4 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 packages/app/e2e/mobile/status-ring/README.md create mode 100644 packages/app/e2e/mobile/status-ring/android.mjs create mode 100644 packages/app/e2e/mobile/status-ring/native.tsx diff --git a/packages/app/e2e/mobile/status-ring/README.md b/packages/app/e2e/mobile/status-ring/README.md new file mode 100644 index 0000000000..213af8a580 --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/README.md @@ -0,0 +1,45 @@ +# Native retained status-ring regression + +This runs the production native ring and clock inside real retained panels with +Reanimated on Android. A probe consumes the same public rotation hook and counts +UI-runtime value changes. No Reanimated mocks or provider processes are used. + +Use an Android development client compatible with the checkout's native dependencies. +In a disposable checkout, temporarily replace `packages/app/index.ts` with: + +```tsx +import "./src/polyfills/install-early"; +import "./src/styles/unistyles"; +import { registerRootComponent } from "expo"; +import NativeRingRegression from "./e2e/mobile/status-ring/native"; +registerRootComponent(NativeRingRegression); +``` + +Start Metro in `packages/app`: + +```sh +APP_VARIANT=development npx expo start --localhost --port 8096 +``` + +With one Android emulator running, open that bundle: + +```sh +agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096 +``` + +From the repository root: + +```sh +node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096 +``` + +The test checks visible rotation, hidden-panel inactivity while another ring is +visible, inactivity with both panels hidden, and synchronized resumption. Each +sample reads counters on the UI runtime; elapsed time includes device-command +latency. The baseline fails at the first hidden-panel assertion. + +Restore the original `packages/app/index.ts`, close the agent-device session, +and stop Metro afterward. The fixture substitutes running rings for a live agent +and toggles retained panels directly; it does not exercise workspace navigation +or measure whole-app CPU usage. It is excluded from routine unit tests because +it requires a native runtime. diff --git a/packages/app/e2e/mobile/status-ring/android.mjs b/packages/app/e2e/mobile/status-ring/android.mjs new file mode 100644 index 0000000000..7b5735fa2b --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/android.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; + +const [, , session, metroUrl] = process.argv; +assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx"); +function ad(...args) { + return execFileSync("agent-device", ["--session", session, ...args], { + encoding: "utf8", + timeout: 60_000, + }); +} +function evaluate(expression) { + const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim(); + return value === "undefined" ? undefined : JSON.parse(value); +} +let target; +const deadline = Date.now() + 30_000; +do { + target = ad("cdp", "target", "list", "--url", metroUrl) + .split("\n") + .find((line) => line.includes("sh.paseo.debug")); + if (!target) await delay(250); +} while (!target && Date.now() < deadline); +assert(target, "Open the Android dev client on this Metro bundle first"); +ad("cdp", "target", "select", target.split(/\s+/)[0]); +let ready = false; +do { + ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2"); + if (!ready) await delay(250); +} while (!ready && Date.now() < deadline); +assert(ready, "The two native probes must mount before sampling"); +async function sample() { + evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())"); + await delay(100); + return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)")); +} +async function observe(label) { + const before = await sample(); + await delay(600); + const after = await sample(); + const deltas = Object.fromEntries( + Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]), + ); + console.log(JSON.stringify({ label, deltas, after })); + return { deltas, after }; +} +const visible = await observe("both visible"); +assert(visible.deltas.first > 0 && visible.deltas.second > 0); +assert.equal(visible.after.first.rotation, visible.after.second.rotation); +ad("press", 'label="HIDE FIRST"'); +await delay(200); +const hidden = await observe("first hidden, second visible"); +assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates"); +assert(hidden.deltas.second > 0, "Visible rings must continue rotating"); +ad("press", 'label="HIDE SECOND"'); +await delay(200); +const allHidden = await observe("both hidden"); +assert.equal(allHidden.deltas.first, 0); +assert.equal(allHidden.deltas.second, 0); +ad("press", 'label="SHOW FIRST"'); +ad("press", 'label="SHOW SECOND"'); +await delay(200); +const restored = await observe("both shown again"); +assert(restored.deltas.first > 0 && restored.deltas.second > 0); +assert.equal( + restored.after.first.rotation, + restored.after.second.rotation, + "Restored rings must share the current phase", +); +console.log("PASS native retained-ring lifecycle"); diff --git a/packages/app/e2e/mobile/status-ring/native.tsx b/packages/app/e2e/mobile/status-ring/native.tsx new file mode 100644 index 0000000000..987a225431 --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/native.tsx @@ -0,0 +1,69 @@ +import { useCallback, useLayoutEffect, useState } from "react"; +import { Button, Text, View } from "react-native"; +import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; +import { RetainedPanel } from "@/components/retained-panel"; +import { useStatusRingRotation } from "@/components/status-ring/clock"; +import { StatusRing } from "@/components/status-ring"; + +interface Sample { + updates: number; + rotation: number; +} + +declare global { + var __ringSamples: Record; + var __ringReaders: Record void>; +} + +function recordSample(name: string, value: { updates: number; rotation: number }) { + globalThis.__ringSamples[name] = value; +} + +function Probe({ name }: { name: string }) { + const rotation = useStatusRingRotation(); + const updates = useSharedValue(0); + useAnimatedReaction( + () => rotation.value, + () => { + updates.value += 1; + }, + ); + // Read the real UI runtime, without scheduling a continuous JS observer. + useLayoutEffect(() => { + globalThis.__ringSamples ??= {}; + globalThis.__ringReaders ??= {}; + globalThis.__ringReaders[name] = () => + scheduleOnUI(() => { + "worklet"; + const sample = { updates: updates.value, rotation: rotation.value }; + scheduleOnRN(recordSample, name, sample); + }); + return () => { + delete globalThis.__ringReaders[name]; + }; + }, [name, rotation, updates]); + return ; +} + +const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" }; + +export default function NativeRingRegression() { + const [first, setFirst] = useState(true); + const [second, setSecond] = useState(true); + const toggleFirst = useCallback(() => setFirst((value) => !value), []); + const toggleSecond = useCallback(() => setSecond((value) => !value), []); + return ( + + Native retained status rings + + ); - } - - if (errorMessage) { - if (errorMessage === "File is too large to display") { - return ( - - - - ); - } + } else if (!panel) { + body = ( + <> + {t("panels.file.binaryPreviewUnavailable")} + {formatFileSize(snapshot.size)} + + ); + } else { return ( - - {errorMessage} - - - - ); - } - - return ( - - {preview ? ( - - ) : null} - - - ); -} - -function EditableFilePane({ - panel, - serverId, - workspaceRoot, - client, - cwd, - path, - preview, - liveFile, - onRetryRead, - retryingRead, - filename, - mode, - onModeChange, - isLoading, - isMobile, - location, - navigationRevision, -}: { - panel: FilePanel | null; - serverId: string; - workspaceRoot: string; - client: DaemonClient; - cwd: string; - path: string; - preview: TextExplorerFile; - liveFile: LiveFileModel; - onRetryRead: () => void; - retryingRead: boolean; - filename: string; - mode?: "preview" | "source"; - onModeChange?: (mode: "preview" | "source") => void; - isLoading: boolean; - isMobile: boolean; - location: WorkspaceFileLocation; - navigationRevision: number; -}) { - const { settings } = useAppSettings(); - const { t } = useTranslation(); - const [cursor, setCursor] = useState({ line: 1, column: 1 }); - const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); - const session = useMemo( - () => ({ - write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) { - return client.writeFile({ cwd, path, ...input }); - }, - }), - [client, cwd, path], - ); - const [model] = useState(() => { - return new FileEditorModel({ - file: { - content: preview.content ?? "", - hasBom: preview.hasBom, - version: { - status: "ready", - cwd, - path, - size: preview.size, - modifiedAt: preview.modifiedAt, - revision: preview.revision, - }, - }, - session, - }); - }); - useEffect(() => { - const source = createFileObservationSource(liveFile); - model.connectFileObservations(source); - return () => model.disconnectFileObservations(); - }, [liveFile, model]); - const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); - usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave }); - useEffect(() => () => model.dispose(), [model]); - - const handleReload = useCallback(() => { - if (!snapshot.modified) { - void model.reload(); - return; - } - void (async () => { - const confirmed = await confirmDialog({ - title: t("panels.file.editor.reloadTitle"), - message: t("panels.file.editor.reloadMessage"), - confirmLabel: t("panels.file.editor.reload"), - destructive: true, - }); - if (confirmed) void model.reload(); - })(); - }, [model, snapshot.modified, t]); - const handleOverwrite = useCallback(() => void model.overwrite(), [model]); - const conflict = fileConflictAlertState({ - callout: getFileConflictCallout(snapshot), - onOverwrite: handleOverwrite, - onReload: handleReload, - onRetry: onRetryRead, - retrying: retryingRead, - }); - const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []); - const renderedPreview = useMemo( - () => ({ - ...preview, - content: snapshot.content, - size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size, - modifiedAt: - snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt, - }), - [preview, snapshot.content, snapshot.version], - ); - const showSource = mode !== "preview"; - - return ( - - - {showSource ? ( - - ) : ( - + + - )} + + ); + } + return ( + + {body} ); } -function fileConflictAlertState(input: { - callout: FileConflictCallout | null; - onOverwrite(): void; - onReload(): void; - onRetry(): void; - retrying: boolean; -}): FileConflictAlertState | undefined { - if (!input.callout) return undefined; - if (input.callout.kind === "deleted") return { kind: "deleted" }; - if (input.callout.kind === "checkFailed") { - return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry }; - } - return { - kind: "changed", - canOverwrite: input.callout.canOverwrite, - onReload: input.onReload, - onOverwrite: input.onOverwrite, - }; +/** One panel mount. Its tab reports unsaved changes until the panel unmounts. */ +function MountedFilePanel({ + panel, + file, + location, + target, + onModifiedChange, +}: { + panel: FilePanel; + file: PluginFile; + location: { lineStart?: number; lineEnd?: number; revision: number }; + target: { serverId: string; workspaceRoot: string; path: string }; + onModifiedChange: (modified: boolean) => void; +}) { + const [tab] = useState(() => ({ + setModified: (modified) => onModifiedChange(modified), + })); + useEffect(() => () => onModifiedChange(false), [onModifiedChange]); + return ; } const styles = StyleSheet.create((theme) => ({ @@ -627,24 +163,14 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[3], padding: theme.spacing[4], }, - loadingText: { - marginTop: theme.spacing[2], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, errorText: { color: theme.colors.destructive, fontSize: theme.fontSize.base, textAlign: "center", }, - emptyText: { + mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, textAlign: "center", }, - binaryMetaText: { - marginTop: theme.spacing[2], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, })); diff --git a/packages/app/src/file-pane/preview-lifecycle/hook.ts b/packages/app/src/file-pane/preview-lifecycle/hook.ts deleted file mode 100644 index 04d8545f73..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/hook.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useState, useSyncExternalStore } from "react"; -import type { LiveFileSnapshot } from "../live-file/model"; -import { - createFilePanePreview, - FilePreviewLifecycleModel, - type FilePreviewLifecycleSnapshot, -} from "./model"; - -/** Bridges the raw-file owner to the preview preparation owner. */ -export function useFilePreview(input: { - targetKey: string | null; - liveFileSnapshot: LiveFileSnapshot; -}): FilePreviewLifecycleSnapshot { - const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview)); - const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const { targetKey, liveFileSnapshot } = input; - - useEffect(() => { - model.setSource({ targetKey, liveFileSnapshot }); - }, [liveFileSnapshot, model, targetKey]); - - return snapshot; -} diff --git a/packages/app/src/file-pane/preview-lifecycle/model.test.ts b/packages/app/src/file-pane/preview-lifecycle/model.test.ts deleted file mode 100644 index a934e69b58..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { LiveFileSnapshot } from "../live-file/model"; -import { describe, expect, it } from "vitest"; -import { - FilePreviewLifecycleModel, - type FilePanePreview, - type FilePreviewLifecycleSnapshot, -} from "./model"; - -function previewFile(content: string) { - return { - kind: "text" as const, - path: "file.ts", - size: 7, - encoding: "utf-8" as const, - hasBom: false, - modifiedAt: "2026-08-20T00:00:00.000Z", - content, - }; -} - -interface Deferred { - promise: Promise; - resolve(value: T): void; - reject(error: Error): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((onResolve, onReject) => { - resolve = onResolve; - reject = onReject; - }); - return { promise, resolve, reject }; -} - -function file(revision = "revision-1", path = "file.ts"): FileReadResult { - return { - bytes: new TextEncoder().encode("preview"), - mime: "text/plain", - size: 7, - path, - kind: "text", - modifiedAt: "2026-08-20T00:00:00.000Z", - revision, - }; -} - -function pending(): LiveFileSnapshot { - return { observation: null, read: { status: "pending", requested: false } }; -} - -function completed(rawFile: FileReadResult): LiveFileSnapshot { - return { - observation: { - status: "ready", - file: rawFile, - version: { - status: "ready", - cwd: "/workspace", - path: rawFile.path, - size: rawFile.size, - modifiedAt: rawFile.modifiedAt, - revision: rawFile.revision, - }, - }, - read: { status: "idle" }, - }; -} - -function source( - targetKey: string, - snapshot: LiveFileSnapshot, -): { - targetKey: string; - liveFileSnapshot: LiveFileSnapshot; -} { - return { targetKey, liveFileSnapshot: snapshot }; -} - -describe("FilePreviewLifecycleModel", () => { - it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => { - const first = deferred(); - const second = deferred(); - const third = deferred(); - const preparations = [first, second, third]; - const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise); - const preview: FilePanePreview = { - file: previewFile("preview"), - imageAttachment: null, - }; - - model.setSource(source("/workspace:file.ts", pending())); - expect(model.getSnapshot()).toEqual({ status: "read_pending" }); - - model.setSource(source("/workspace:file.ts", completed(file("one")))); - expect(model.getSnapshot()).toEqual({ status: "preparing" }); - first.resolve(preview); - await expectSnapshot(model, { status: "ready", preview }); - - model.setSource(source("/workspace:file.ts", pending())); - expect(model.getSnapshot()).toEqual({ status: "read_pending", preview }); - - model.setSource( - source("/workspace:file.ts", { - observation: { - status: "error", - cwd: "/workspace", - path: "file.ts", - error: "Requested path is not a file", - }, - read: { status: "pending", requested: true }, - }), - ); - expect(model.getSnapshot()).toEqual({ status: "read_pending", preview }); - - model.setSource(source("/workspace:file.ts", completed(file("two")))); - expect(model.getSnapshot()).toEqual({ status: "preparing", preview }); - second.resolve(null); - await expectSnapshot(model, { status: "unsupported" }); - - model.setSource(source("/workspace:file.ts", completed(file("three")))); - expect(model.getSnapshot()).toEqual({ status: "preparing" }); - third.reject(new Error("attachment failed")); - await expectSnapshot(model, { status: "error", message: "attachment failed" }); - }); - - it("does not publish a stale conversion after retargeting", async () => { - const first = deferred(); - const second = deferred(); - const model = new FilePreviewLifecycleModel((rawFile) => - rawFile.revision === "one" ? first.promise : second.promise, - ); - const nextPreview: FilePanePreview = { - file: previewFile("two"), - imageAttachment: null, - }; - - model.setSource(source("/workspace:file.ts", completed(file("one")))); - model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts")))); - first.resolve({ file: previewFile("one"), imageAttachment: null }); - await Promise.resolve(); - expect(model.getSnapshot()).toEqual({ status: "preparing" }); - - second.resolve(nextPreview); - await expectSnapshot(model, { status: "ready", preview: nextPreview }); - }); -}); - -async function expectSnapshot( - model: FilePreviewLifecycleModel, - expected: FilePreviewLifecycleSnapshot, -): Promise { - await new Promise((resolve) => queueMicrotask(resolve)); - expect(model.getSnapshot()).toEqual(expected); -} diff --git a/packages/app/src/file-pane/preview-lifecycle/model.ts b/packages/app/src/file-pane/preview-lifecycle/model.ts deleted file mode 100644 index fe69f2227b..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { AttachmentMetadata } from "@/attachments/types"; -import { persistAttachmentFromBytes } from "@/attachments/service"; -import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; -import { explorerFileFromReadResult } from "@/file-explorer/read-result"; -import type { ExplorerFile } from "@/stores/session-store"; -import type { LiveFileSnapshot } from "../live-file/model"; - -export interface FilePanePreview { - file: ExplorerFile; - imageAttachment: AttachmentMetadata | null; -} - -export type FilePreviewLifecycleSnapshot = - | { status: "initial" } - | { status: "read_pending"; preview?: FilePanePreview } - | { status: "preparing"; preview?: FilePanePreview } - | { status: "ready"; preview: FilePanePreview } - | { status: "error"; message: string; preview?: FilePanePreview } - | { status: "unsupported" }; - -const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" }; - -/** Converts a completed raw read into the preview resources consumed by FilePane. */ -export async function createFilePanePreview(file: FileReadResult): Promise { - const explorerFile = explorerFileFromReadResult(file); - if (file.kind !== "image") { - return { file: explorerFile, imageAttachment: null }; - } - - const imageAttachment = await persistAttachmentFromBytes({ - id: createPreviewAttachmentId({ - mimeType: file.mime, - path: file.path, - size: file.size, - modifiedAt: file.modifiedAt, - contentLength: file.bytes.byteLength, - }), - bytes: file.bytes, - mimeType: file.mime, - fileName: getFileNameFromPath(file.path), - }); - - return { file: explorerFile, imageAttachment }; -} - -/** Owns conversion after LiveFileModel has produced a raw file snapshot. */ -export class FilePreviewLifecycleModel { - private readonly listeners = new Set<() => void>(); - private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot; - private generation = 0; - private preparedSourceKey: string | null = null; - private targetKey: string | null = null; - - constructor( - private readonly prepare: (file: FileReadResult) => Promise, - ) {} - - subscribe = (listener: () => void): (() => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot; - - setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void { - const targetChanged = input.targetKey !== this.targetKey; - const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot); - if (targetChanged) { - this.targetKey = input.targetKey; - this.preparedSourceKey = null; - this.generation += 1; - } - - const source = getReadySource(input); - if (source) { - if (source.key === this.preparedSourceKey) { - return; - } - this.preparedSourceKey = source.key; - const generation = ++this.generation; - this.setSnapshot({ - status: "preparing", - ...(retainedPreview ? { preview: retainedPreview } : {}), - }); - void this.prepareSource({ file: source.file, generation, retainedPreview }); - return; - } - - const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot); - if (nextSnapshot.status === "initial" && retainedPreview) { - return; - } - const retainedSnapshot = retainedPreview - ? { ...nextSnapshot, preview: retainedPreview } - : nextSnapshot; - if (sameNonReadyState(this.snapshot, retainedSnapshot)) { - return; - } - this.preparedSourceKey = null; - this.generation += 1; - this.setSnapshot(retainedSnapshot); - } - - private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void { - this.snapshot = snapshot; - for (const listener of this.listeners) listener(); - } - - private async prepareSource(input: { - file: FileReadResult; - generation: number; - retainedPreview?: FilePanePreview; - }): Promise { - try { - const preview = await this.prepare(input.file); - if (input.generation !== this.generation) return; - this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" }); - } catch (error) { - if (input.generation !== this.generation) return; - this.setSnapshot({ - status: "error", - message: errorMessage(error), - ...(input.retainedPreview ? { preview: input.retainedPreview } : {}), - }); - } - } -} - -export function filePreviewFromLifecycle( - snapshot: FilePreviewLifecycleSnapshot, -): FilePanePreview | undefined { - return "preview" in snapshot ? snapshot.preview : undefined; -} - -export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): { - file: ExplorerFile | null; - imageAttachment: AttachmentMetadata | null; -} { - const preview = filePreviewFromLifecycle(snapshot); - return preview ?? { file: null, imageAttachment: null }; -} - -function getReadySource(input: { - targetKey: string | null; - liveFileSnapshot: LiveFileSnapshot; -}): { key: string; file: FileReadResult } | null { - const observation = input.liveFileSnapshot.observation; - if (!input.targetKey || observation?.status !== "ready") { - return null; - } - const { version } = observation; - if (input.targetKey !== `${version.cwd}:${version.path}`) { - return null; - } - return { - key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`, - file: observation.file, - }; -} - -function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot { - if (snapshot.read.status === "error") { - return { status: "error", message: snapshot.read.error }; - } - if (snapshot.read.status === "pending") { - return { status: "read_pending" }; - } - if (snapshot.observation?.status === "error") { - return { status: "error", message: snapshot.observation.error }; - } - if (snapshot.observation?.status === "missing") { - return { status: "error", message: "File not found" }; - } - return initialSnapshot; -} - -function sameNonReadyState( - current: FilePreviewLifecycleSnapshot, - next: FilePreviewLifecycleSnapshot, -): boolean { - if (current.status !== next.status) { - return false; - } - return current.status !== "error" || next.status !== "error" || current.message === next.message; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to prepare preview"; -} diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx deleted file mode 100644 index de7c5eeae1..0000000000 --- a/packages/app/src/file-pane/source/view.native.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { FlatList, Text, View, type ListRenderItem } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; -import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; -import type { WorkspaceFileLocation } from "@/workspace/file-open"; -import type { EditorVisualTheme } from "../editor/extensions.web"; -import { selectSourcePresentation } from "./presentation"; - -interface FileSourceViewProps { - content: string; - filename: string; - location: WorkspaceFileLocation; - navigationRevision: number; - size: number; - theme: EditorVisualTheme; - tooLargeMessage: string; -} - -interface SourceLine { - number: number; - tokens: HighlightToken[]; -} - -export function FileSourceView({ - content, - filename, - location, - navigationRevision, - size, - tooLargeMessage, -}: FileSourceViewProps) { - const presentation = selectSourcePresentation({ size, platform: "native" }); - if (presentation === "unsupported") { - return ( - - {tooLargeMessage} - - ); - } - return ( - - ); -} - -function VirtualizedSource({ - content, - filename, - location, - navigationRevision, - presentation, -}: Omit & { - presentation: "highlighted" | "plain"; -}) { - const listRef = useRef>(null); - const lines = useMemo(() => { - if (presentation === "highlighted") - return highlightCode(content, filename).map((tokens, index) => ({ - number: index + 1, - tokens, - })); - return content - .split("\n") - .map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] })); - }, [content, filename, presentation]); - useEffect(() => { - if (!location.lineStart) return; - listRef.current?.scrollToIndex({ - index: Math.min(location.lineStart - 1, lines.length - 1), - animated: false, - viewPosition: 0.5, - }); - }, [lines.length, location.lineStart, navigationRevision]); - return ( - - ); -} - -function SourceLineView({ line }: { line: SourceLine }) { - return ( - - {line.number} - - {line.tokens.map((token) => ( - - {token.text} - - ))} - - - ); -} - -function sourceLineKey(line: SourceLine): string { - return String(line.number); -} - -function sourceLineLayout(_data: ArrayLike | null | undefined, index: number) { - return { length: 20, offset: index * 20, index }; -} - -const renderSourceLine: ListRenderItem = ({ item }) => ; - -const styles = StyleSheet.create((theme) => ({ - unsupported: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: theme.spacing[4], - }, - unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, - line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 }, - gutter: { - width: 56, - paddingRight: theme.spacing[3], - textAlign: "right", - color: theme.colors.foregroundMuted, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - }, - text: { - flex: 1, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - lineHeight: theme.fontSize.code * 1.45, - }, -})); diff --git a/packages/app/src/file-panels/associations.ts b/packages/app/src/file-panels/associations.ts new file mode 100644 index 0000000000..6280bf5556 --- /dev/null +++ b/packages/app/src/file-panels/associations.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; + +export interface FilePanelAssociations { + /** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */ + associations: Readonly>; + /** Writes the association for a selector key, or forgets it with null. */ + remember(selectorKey: string, panelId: string | null): void; +} + +/** The one reader and writer of the device's file panel associations. */ +export function useFilePanelAssociations(): FilePanelAssociations { + const { settings, updateSettings } = useAppSettings(); + const remember = useCallback( + (selectorKey: string, panelId: string | null) => { + void updateSettings((current) => ({ + filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId), + })); + }, + [updateSettings], + ); + return { associations: settings.filePanelAssociations, remember }; +} + +function withAssociation( + associations: Readonly>, + selectorKey: string, + panelId: string | null, +): Record { + const next = { ...associations }; + if (panelId === null) delete next[selectorKey]; + else next[selectorKey] = panelId; + return next; +} diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx index ba1c4e2cd3..8a9466b45e 100644 --- a/packages/app/src/file-panels/builtin.tsx +++ b/packages/app/src/file-panels/builtin.tsx @@ -1,92 +1,94 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useFileText, useFileUrl } from "@getpaseo/plugin/client"; +import { useCallback, useState } from "react"; import { ScrollView, Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { StyleSheet } from "react-native-unistyles"; import { ZoomableImage } from "@/components/zoomable-viewport/image"; -import type { Theme } from "@/styles/theme"; +import { useSessionStore } from "@/stores/session-store"; import { FileHtmlPreview } from "./html-preview"; +import { FilePanelLoading } from "./loading"; import { FileMarkdownPreview } from "./markdown-preview"; -import type { builtinFilePanels } from "./catalog"; +import type { BuiltinFilePanelProps } from "./panels"; +import { SourcePanel } from "./source/panel"; +import type { RenderingFilePanel, SourceFilePanel } from "./catalog"; -interface FilePanelPreviewProps { - content: string; - filePath: string; - workspaceRoot: string; - client: DaemonClient | null; - serverId: string; - imagePreviewUri: string | null; -} - -function MarkdownPanel(props: FilePanelPreviewProps) { +/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */ +function MarkdownPanel({ file, target }: BuiltinFilePanelProps) { + const text = useFileText(file); + const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null); + const [rendered, setRendered] = useState(false); + const handleLayout = useCallback(() => setRendered(true), []); + if (text.status === "loading") return ; + if (text.status === "error") return ; return ( - - - + + + + + + {rendered ? null : } ); } -function HtmlPanel({ content }: FilePanelPreviewProps) { +function HtmlPanel({ file }: BuiltinFilePanelProps) { + const text = useFileText(file); + if (text.status === "loading") return ; + if (text.status === "error") return ; return ( - - + + ); } -const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +function ImagePanel({ file }: BuiltinFilePanelProps) { + const url = useFileUrl(file); + if (url.status === "loading") return ; + if (url.status === "error") return ; + return ; +} -function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) { - const { t } = useTranslation(); - if (!imagePreviewUri) { - return ( - - - {t("panels.file.loading")} - - ); - } - return ; +function FilePanelError({ message }: { message: string }) { + return ( + + {message} + + ); } const components = { "builtin:markdown": MarkdownPanel, "builtin:html": HtmlPanel, "builtin:image": ImagePanel, + "builtin:source": SourcePanel, }; -export function FilePanelPreview({ +export function BuiltinFilePanelView({ panel, ...props -}: FilePanelPreviewProps & { - panel: (typeof builtinFilePanels)[number]; -}) { +}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { const Component = components[panel.id]; return ; } const styles = StyleSheet.create((theme) => ({ - previewScrollContainer: { flex: 1, minHeight: 0 }, - previewContent: { flex: 1, minHeight: 0 }, + container: { flex: 1, minHeight: 0 }, centerState: { flex: 1, alignItems: "center", justifyContent: "center", - gap: theme.spacing[3], padding: theme.spacing[4], }, - loadingText: { - marginTop: theme.spacing[2], - color: theme.colors.foregroundMuted, + errorText: { + color: theme.colors.destructive, fontSize: theme.fontSize.base, + textAlign: "center", }, })); diff --git a/packages/app/src/file-panels/catalog.test.ts b/packages/app/src/file-panels/catalog.test.ts index 2052224db2..89a7e14cc7 100644 --- a/packages/app/src/file-panels/catalog.test.ts +++ b/packages/app/src/file-panels/catalog.test.ts @@ -1,86 +1,74 @@ import { describe, expect, it } from "vitest"; -import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog"; -import { resolveFilePanel } from "./resolve"; +import { filePanelCatalog } from "./catalog"; +import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve"; -function filePreviewRenderKind(path: string) { - const { panel } = resolveFilePanel({ - file: { path, kind: "text", size: 100, hasBytes: false }, - candidates: builtinFilePanels, - associations: {}, - }); - return panel ? panel.id.slice("builtin:".length) : null; +const builtins = filePanelCatalog([]); + +const richMarkdown: FilePanelCandidate = { + id: "plugin:notes/rich", + builtin: false, + files: { extensions: ["md"] }, +}; + +function textFile(path: string): FilePanelFile { + return { path, kind: "text" }; } -describe("isRenderedMarkdownFile", () => { - it("detects .md files", () => { - expect(isRenderedMarkdownFile("README.md")).toBe(true); - expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true); - }); +function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) { + return resolveFilePanel({ file, candidates, associations: {} }).candidates.map( + (candidate) => candidate.id, + ); +} - it("detects .markdown files", () => { - expect(isRenderedMarkdownFile("notes.markdown")).toBe(true); - expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true); - }); - - it("does not treat .mdx files as rendered markdown", () => { - expect(isRenderedMarkdownFile("page.mdx")).toBe(false); - }); - - it("does not treat other text files as rendered markdown", () => { - expect(isRenderedMarkdownFile("src/index.ts")).toBe(false); - expect(isRenderedMarkdownFile("README.md.txt")).toBe(false); - expect(isRenderedMarkdownFile("plan.html")).toBe(false); - }); -}); - -describe("filePreviewRenderKind", () => { - it("maps each renderable extension to its kind", () => { - expect(filePreviewRenderKind("README.md")).toBe("markdown"); - expect(filePreviewRenderKind("notes.markdown")).toBe("markdown"); - expect(filePreviewRenderKind("plan.html")).toBe("html"); - expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html"); - expect(filePreviewRenderKind("plan.htm")).toBe("html"); - }); - - it("returns null for files without a rendered preview", () => { - expect(filePreviewRenderKind("src/index.ts")).toBe(null); - expect(filePreviewRenderKind("page.mdx")).toBe(null); - expect(filePreviewRenderKind("index.html.erb")).toBe(null); - }); -}); +function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) { + return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null; +} describe("built-in file panels", () => { + it("opens each rendered extension with its panel and Source after it", () => { + expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]); + expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown"); + expect(panelId(textFile("plan.html"))).toBe("builtin:html"); + expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html"); + expect(panelId(textFile("plan.htm"))).toBe("builtin:html"); + }); + + it("opens every other text file with Source, whatever its name", () => { + for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) { + expect(candidateIds(textFile(path))).toEqual(["builtin:source"]); + } + }); + + it("puts plugin panels after the rendering built-ins and before Source", () => { + expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([ + "builtin:markdown", + richMarkdown.id, + "builtin:source", + ]); + const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]); + expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id); + }); + + it("has no Source for files that are not text", () => { + expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]); + }); + it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])( "registers the image extension %s", (extension) => { const resolution = resolveFilePanel({ - file: { - path: `/capture.${extension.toUpperCase()}`, - kind: "image", - size: 512, - hasBytes: true, - }, - candidates: builtinFilePanels, + file: { path: `/capture.${extension.toUpperCase()}`, kind: "image" }, + candidates: builtins, associations: { [`ext:${extension}`]: "builtin:markdown" }, }); expect(resolution.panel?.id).toBe("builtin:image"); expect( resolveFilePanel({ - file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false }, - candidates: builtinFilePanels, + file: { path: "capture.png", kind: "binary" }, + candidates: builtins, associations: {}, }).panel, ).toBeNull(); }, ); - - it("does not impose a new preview size limit on built-in text panels", () => { - expect( - resolveFilePanel({ - file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false }, - candidates: builtinFilePanels, - associations: {}, - }).panel?.id, - ).toBe("builtin:markdown"); - }); }); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts index fd350ec1ff..6ff5cd8d5a 100644 --- a/packages/app/src/file-panels/catalog.ts +++ b/packages/app/src/file-panels/catalog.ts @@ -1,28 +1,43 @@ -import { filePanelExtension, type FilePanelCandidate } from "./resolve"; +import type { FilePanelCandidate } from "./resolve"; -const markdownPanel = { - id: "builtin:markdown", - files: { extensions: ["md", "markdown"] }, - document: "text", - maxBytes: Infinity, -} as const satisfies FilePanelCandidate; - -export const builtinFilePanels = [ - markdownPanel, +const renderingPanels = [ + { + id: "builtin:markdown", + builtin: true, + files: { extensions: ["md", "markdown"] }, + fileKind: "text", + }, { id: "builtin:html", + builtin: true, files: { extensions: ["html", "htm"] }, - document: "text", - maxBytes: Infinity, + fileKind: "text", }, { id: "builtin:image", + builtin: true, files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] }, - document: "bytes", - maxBytes: Infinity, + fileKind: "image", }, ] as const satisfies readonly FilePanelCandidate[]; -export function isRenderedMarkdownFile(path: string): boolean { - return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path)); +/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */ +export const sourceFilePanel = { + id: "builtin:source", + builtin: true, + files: "all", + fileKind: "text", +} as const satisfies FilePanelCandidate; + +export type RenderingFilePanel = (typeof renderingPanels)[number]; +export type SourceFilePanel = typeof sourceFilePanel; + +/** + * Every panel a host offers, in resolver order: the built-ins that render a file, then plugin + * panels, then Source. Source is last so it is the default only when nothing else claims the file. + */ +export function filePanelCatalog( + pluginPanels: readonly Plugin[], +): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] { + return [...renderingPanels, ...pluginPanels, sourceFilePanel]; } diff --git a/packages/app/src/file-panels/choice/hook.ts b/packages/app/src/file-panels/choice/hook.ts new file mode 100644 index 0000000000..0e51a69c4b --- /dev/null +++ b/packages/app/src/file-panels/choice/hook.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; +import { useFilePanelAssociations } from "../associations"; +import { sourceFilePanel } from "../catalog"; +import { useFilePanels, type FilePanel } from "../panels"; +import { resolveFilePanel, type FilePanelFileKind } from "../resolve"; +import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model"; +import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry"; + +export type FilePanelChoice = FilePanelChoiceModel; + +/** + * The file tab's panel: resolves it from the catalog, the device associations and this tab's + * choice, and owns the picker and "Open with…" for it. + */ +export function useFilePanelChoice(input: { + serverId: string; + targetKey: string | null; + path: string; + /** The kind the file's stat reports, or null before the stat. */ + fileKind: FilePanelFileKind | null; + /** The tab is on screen. */ + presentable: boolean; + /** Identifies the tab's latest request to show a line, or null when it targets no line. */ + lineNavigation: number | null; +}): { panel: FilePanel | null; choice: FilePanelChoice } { + const { targetKey, path, fileKind, presentable, lineNavigation } = input; + const { serverId, workspaceId, tabId } = usePaneContext(); + const candidates = useFilePanels(input.serverId); + const { associations, remember } = useFilePanelAssociations(); + const [choice] = useState( + () => new FilePanelChoiceModel({ remember, linePanelId: sourceFilePanel.id }), + ); + // From the subscribed snapshot: React Compiler caches reads off the stable model object. + const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey); + const resolution = useMemo( + () => + fileKind + ? resolveFilePanel({ + file: { path, kind: fileKind }, + candidates, + associations, + choice: chosenPanelId, + }) + : null, + [associations, candidates, chosenPanelId, fileKind, path], + ); + + useEffect(() => { + choice.observe({ targetKey, resolution, presentable, lineNavigation }); + }, [choice, lineNavigation, presentable, resolution, targetKey]); + useEffect( + () => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice), + [choice, serverId, tabId, workspaceId], + ); + + return { panel: resolution?.panel ?? null, choice }; +} + +const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot = { + targetKey: null, + chosenPanelId: null, + picker: null, + openWith: null, +}; +const subscribeNothing = () => () => {}; +const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT; + +/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */ +export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null { + const { serverId, workspaceId, tabId } = identity; + const getSnapshot = useCallback( + () => getFilePanelChoice({ serverId, workspaceId, tabId }), + [serverId, tabId, workspaceId], + ); + return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot); +} + +export function useFilePanelChoiceSnapshot( + choice: FilePanelChoice | null, +): FilePanelChoiceSnapshot { + return useSyncExternalStore( + choice?.subscribe ?? subscribeNothing, + choice?.getSnapshot ?? getNoChoiceSnapshot, + choice?.getSnapshot ?? getNoChoiceSnapshot, + ); +} diff --git a/packages/app/src/file-panels/choice/model.test.ts b/packages/app/src/file-panels/choice/model.test.ts new file mode 100644 index 0000000000..e9e5e83042 --- /dev/null +++ b/packages/app/src/file-panels/choice/model.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { filePanelCatalog, sourceFilePanel } from "../catalog"; +import { resolveFilePanel, type FilePanelCandidate } from "../resolve"; +import { chosenPanelFor, FilePanelChoiceModel } from "./model"; + +const rich: FilePanelCandidate = { + id: "plugin:notes/rich", + builtin: false, + files: { extensions: ["md", "markdown"] }, +}; +const markdown = "builtin:markdown"; +const source = sourceFilePanel.id; + +/** A file tab: resolves against the shared associations and feeds the result to its model. */ +function openTab( + associations: Record, + candidates: FilePanelCandidate[] = [...filePanelCatalog([rich])], + options: { presentable?: boolean; path?: string; lineNavigation?: number | null } = {}, +) { + const model = new FilePanelChoiceModel({ + remember(selectorKey, panelId) { + if (panelId === null) delete associations[selectorKey]; + else associations[selectorKey] = panelId; + }, + linePanelId: source, + }); + let targetKey = `/repo:${options.path ?? "notes.md"}`; + let presentable = options.presentable ?? true; + let lineNavigation = options.lineNavigation ?? null; + const resolve = () => + resolveFilePanel({ + file: { path: targetKey.split(":")[1]!, kind: "text" }, + candidates, + associations, + choice: chosenPanelFor(model.getSnapshot(), targetKey), + }); + // Like the hook: observe, then render what the model's choice resolves to. + const render = () => { + model.observe({ targetKey, resolution: resolve(), presentable, lineNavigation }); + return resolve().panel?.id ?? null; + }; + render(); + return { + model, + render, + panel: () => render(), + picker: () => model.getSnapshot().picker, + retarget(path: string) { + targetKey = `/repo:${path}`; + return render(); + }, + setPresentable(next: boolean) { + presentable = next; + return render(); + }, + navigateToLine(revision: number) { + lineNavigation = revision; + return render(); + }, + }; +} + +describe("FilePanelChoiceModel", () => { + it("asks once per tab open over the first candidate, and dismissing keeps it", () => { + const associations = {}; + const tab = openTab(associations); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toMatchObject({ + selectorKey: "ext:md", + selectedPanelId: markdown, + remember: false, + }); + expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([ + markdown, + rich.id, + source, + ]); + tab.model.dismissPicker(); + expect(tab.picker()).toBeNull(); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toBeNull(); + expect(associations).toEqual({}); + + expect(openTab(associations).picker()).not.toBeNull(); + }); + + it("never asks between built-in panels, and switches between them", () => { + const tab = openTab({}, [...filePanelCatalog([])]); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toBeNull(); + expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([ + markdown, + source, + ]); + tab.model.openWith(source); + expect(tab.panel()).toBe(source); + expect(tab.picker()).toBeNull(); + }); + + it("opens a file only Source claims with Source and nothing to choose", () => { + const tab = openTab({}, [...filePanelCatalog([rich])], { path: "index.ts" }); + expect(tab.panel()).toBe(source); + expect(tab.picker()).toBeNull(); + expect(tab.model.getSnapshot().openWith).toBeNull(); + }); + + it("remembers the choice when Don't ask me again is checked", () => { + const associations: Record = {}; + const tab = openTab(associations); + tab.model.selectInPicker(rich.id); + tab.model.setRemember(true); + tab.model.confirmPicker(); + expect(tab.panel()).toBe(rich.id); + expect(associations).toEqual({ "ext:md": rich.id }); + + const next = openTab(associations); + expect(next.panel()).toBe(rich.id); + expect(next.picker()).toBeNull(); + }); + + it("uses an unremembered choice for this tab only and asks again on the next open", () => { + const associations = {}; + const tab = openTab(associations); + tab.model.selectInPicker(rich.id); + tab.model.confirmPicker(); + expect(tab.panel()).toBe(rich.id); + expect(associations).toEqual({}); + + const next = openTab(associations); + expect(next.panel()).toBe(markdown); + expect(next.picker()).not.toBeNull(); + }); + + it("does not ask with an association or while the tab is in the background", () => { + expect(openTab({ "ext:md": rich.id }).picker()).toBeNull(); + expect(openTab({ "ext:md": source }).panel()).toBe(source); + + const background = openTab({}, undefined, { presentable: false }); + expect(background.picker()).toBeNull(); + background.setPresentable(true); + expect(background.picker()).not.toBeNull(); + }); + + it("opens Source for a line target without asking, and again on each new one", () => { + const tab = openTab({}, undefined, { lineNavigation: 1 }); + expect(tab.panel()).toBe(source); + expect(tab.picker()).toBeNull(); + + tab.model.openWith(rich.id); + expect(tab.panel()).toBe(rich.id); + expect(tab.navigateToLine(1)).toBe(rich.id); + expect(tab.navigateToLine(2)).toBe(source); + }); + + it("opens Source for the same line target in a retargeted tab", () => { + const tab = openTab({}, undefined, { lineNavigation: 1 }); + tab.model.openWith(markdown); + expect(tab.retarget("other.md")).toBe(source); + }); + + it("starts a new open when the tab is retargeted", () => { + const tab = openTab({}); + tab.model.selectInPicker(rich.id); + tab.model.confirmPicker(); + expect(tab.retarget("other.md")).toBe(markdown); + expect(tab.picker()).not.toBeNull(); + }); + + it("switches with Open with and toggles Always use without moving the tab", () => { + const associations: Record = {}; + const tab = openTab(associations); + tab.model.dismissPicker(); + expect(tab.model.getSnapshot().openWith).toMatchObject({ + activePanelId: markdown, + remembered: false, + }); + + tab.model.openWith(rich.id); + expect(tab.panel()).toBe(rich.id); + tab.model.toggleAlwaysUse(); + expect(associations).toEqual({ "ext:md": rich.id }); + tab.render(); + expect(tab.model.getSnapshot().openWith?.remembered).toBe(true); + + tab.model.openWith(markdown); + expect(tab.panel()).toBe(markdown); + expect(tab.model.getSnapshot().openWith?.remembered).toBe(false); + tab.model.openWith(rich.id); + tab.render(); + tab.model.toggleAlwaysUse(); + expect(associations).toEqual({}); + expect(tab.panel()).toBe(rich.id); + }); + + it("asks when a plugin panel arrives after the file first rendered", () => { + const candidates: FilePanelCandidate[] = [...filePanelCatalog([])]; + const tab = openTab({}, candidates); + expect(tab.picker()).toBeNull(); + candidates.splice(1, 0, rich); + tab.render(); + expect(tab.picker()).not.toBeNull(); + }); +}); diff --git a/packages/app/src/file-panels/choice/model.ts b/packages/app/src/file-panels/choice/model.ts new file mode 100644 index 0000000000..52cc4072ce --- /dev/null +++ b/packages/app/src/file-panels/choice/model.ts @@ -0,0 +1,195 @@ +import { asksForFilePanel, type FilePanelCandidate, type FilePanelResolution } from "../resolve"; + +/** What the file tab currently resolves; `resolution` is null until the file has been read. */ +export interface FilePanelChoiceSource { + targetKey: string | null; + resolution: FilePanelResolution | null; + /** The tab is on screen, so a picker shown now is seen over the panel it chooses for. */ + presentable: boolean; + /** Identifies the tab's latest request to show a line, or null when it targets no line. */ + lineNavigation: number | null; +} + +export interface FilePanelPickerState { + selectorKey: string; + candidates: readonly Candidate[]; + selectedPanelId: string; + remember: boolean; +} + +export interface FilePanelOpenWithState { + selectorKey: string; + candidates: readonly Candidate[]; + activePanelId: string; + /** The active panel is the one remembered for `selectorKey`. */ + remembered: boolean; +} + +export interface FilePanelChoiceSnapshot { + targetKey: string | null; + /** The panel picked for this tab open. Resolution prefers it over the association. */ + chosenPanelId: string | null; + picker: FilePanelPickerState | null; + /** Present only when the file has more than one candidate; drives the bar's panel switcher. */ + openWith: FilePanelOpenWithState | null; +} + +export interface FilePanelChoiceDeps { + /** Writes (or clears, with null) the device-wide association for a selector key. */ + remember(selectorKey: string, panelId: string | null): void; + /** The panel a line target opens: Source, the panel that shows lines. */ + linePanelId: string; +} + +/** The tab choice that applies to `targetKey`; a choice never outlives its open. */ +export function chosenPanelFor( + snapshot: FilePanelChoiceSnapshot, + targetKey: string | null, +): string | null { + return snapshot.targetKey === targetKey ? snapshot.chosenPanelId : null; +} + +const EMPTY: FilePanelChoiceSnapshot = { + targetKey: null, + chosenPanelId: null, + picker: null, + openWith: null, +}; + +/** + * Which file panel one file tab shows, and whether to ask. + * + * A tab open is one target in one tab: the picker is offered at most once per open, the first + * time the tab is on screen with a plugin panel among several candidates and nothing remembered. + * Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does. + * Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me + * again" and "Always use" write the association every later open reads. + */ +export class FilePanelChoiceModel { + private readonly listeners = new Set<() => void>(); + private snapshot: FilePanelChoiceSnapshot = EMPTY; + private asked = false; + private openedLineNavigation: number | null = null; + + constructor(private readonly deps: FilePanelChoiceDeps) {} + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): FilePanelChoiceSnapshot => this.snapshot; + + observe(source: FilePanelChoiceSource): void { + let current = this.snapshot; + if (source.targetKey !== current.targetKey) { + this.asked = false; + this.openedLineNavigation = null; + current = { ...EMPTY, targetKey: source.targetKey }; + } + if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) { + this.openedLineNavigation = source.lineNavigation; + current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null }; + } + const openWith = stableOpenWith(current.openWith, openWithState(source.resolution)); + let picker = openWith ? current.picker : null; + // Decided once per open, the first time there is something to ask about on screen. An + // association forgotten while the tab stays open is asked about on the next open. + if (!this.asked && source.presentable && openWith && asksForFilePanel(openWith.candidates)) { + this.asked = true; + const remembered = Boolean(source.resolution?.associatedPanelId); + if (!remembered && current.chosenPanelId === null) { + picker = { + selectorKey: openWith.selectorKey, + candidates: openWith.candidates, + selectedPanelId: openWith.activePanelId, + remember: false, + }; + } + } + this.publish({ ...current, openWith, picker }); + } + + selectInPicker(panelId: string): void { + const picker = this.snapshot.picker; + if (!picker) return; + this.publish({ ...this.snapshot, picker: { ...picker, selectedPanelId: panelId } }); + } + + setRemember(remember: boolean): void { + const picker = this.snapshot.picker; + if (!picker) return; + this.publish({ ...this.snapshot, picker: { ...picker, remember } }); + } + + confirmPicker(): void { + const picker = this.snapshot.picker; + if (!picker) return; + if (picker.remember) this.deps.remember(picker.selectorKey, picker.selectedPanelId); + this.publish({ ...this.snapshot, chosenPanelId: picker.selectedPanelId, picker: null }); + } + + /** Keeps whatever the tab shows now; the next open asks again. */ + dismissPicker(): void { + if (!this.snapshot.picker) return; + this.publish({ ...this.snapshot, picker: null }); + } + + openWith(panelId: string): void { + this.publish({ ...this.snapshot, chosenPanelId: panelId, picker: null }); + } + + /** Remembers the active panel, or forgets it when it already is the remembered one. */ + toggleAlwaysUse(): void { + const openWith = this.snapshot.openWith; + if (!openWith) return; + if (!openWith.remembered) { + this.deps.remember(openWith.selectorKey, openWith.activePanelId); + return; + } + // Forgetting must not move the tab off the panel it is showing. + this.publish({ ...this.snapshot, chosenPanelId: openWith.activePanelId }); + this.deps.remember(openWith.selectorKey, null); + } + + private publish(next: FilePanelChoiceSnapshot): void { + const current = this.snapshot; + if ( + current.targetKey === next.targetKey && + current.chosenPanelId === next.chosenPanelId && + current.picker === next.picker && + current.openWith === next.openWith + ) { + return; + } + this.snapshot = next; + for (const listener of this.listeners) listener(); + } +} + +function openWithState( + resolution: FilePanelResolution | null, +): FilePanelOpenWithState | null { + if (!resolution?.panel || resolution.candidates.length < 2) return null; + return { + selectorKey: resolution.selectorKey, + candidates: resolution.candidates, + activePanelId: resolution.panel.id, + remembered: resolution.associatedPanelId === resolution.panel.id, + }; +} + +/** Keeps the published object when nothing in it changed, so subscribers do not re-render. */ +function stableOpenWith( + current: FilePanelOpenWithState | null, + next: FilePanelOpenWithState | null, +): FilePanelOpenWithState | null { + if (!current || !next) return next; + const same = + current.selectorKey === next.selectorKey && + current.activePanelId === next.activePanelId && + current.remembered === next.remembered && + current.candidates.length === next.candidates.length && + current.candidates.every((candidate, index) => candidate === next.candidates[index]); + return same ? current : next; +} diff --git a/packages/app/src/file-panels/choice/open-with.tsx b/packages/app/src/file-panels/choice/open-with.tsx new file mode 100644 index 0000000000..b793b4f445 --- /dev/null +++ b/packages/app/src/file-panels/choice/open-with.tsx @@ -0,0 +1,175 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import { Ellipsis } from "lucide-react-native"; +import { + MenuItem, + MenuSeparator, + MenuSubTrigger, + type MenuPageDefinition, +} from "@/components/ui/menu"; +import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; +import { PluginIconView } from "@/plugins/icons"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import { useFilePanelChoiceSnapshot, useTabFilePanelChoice, type FilePanelChoice } from "./hook"; +import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./presentation"; + +const OPEN_WITH_PAGE_ID = "filePanelOpenWith"; +const MENU_ICON_SIZE = 16; + +const ThemedEllipsis = withUnistyles(Ellipsis); +const ThemedPanelIcon = withUnistyles(PluginIconView); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const NO_PAGES: readonly MenuPageDefinition[] = []; + +export interface FilePanelOpenWithMenu { + /** The "Open with…" row, or null when the file has one panel or none. */ + trigger: ReactElement | null; + /** The page the row opens; pass to the menu surface. */ + pages: readonly MenuPageDefinition[]; +} + +/** "Open with…" for a file tab's context menu, found by the tab's identity. */ +export function useFilePanelOpenWithMenu( + identity: PanelInstanceIdentity, + testIDBase: string, +): FilePanelOpenWithMenu { + return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase); +} + +/** + * The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless + * the file has more than one panel. + */ +export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) { + const { t } = useTranslation(); + const compact = useIsCompactFormFactor(); + const { openWith } = useFilePanelChoiceSnapshot(choice); + if (!openWith) return null; + const label = t("panels.file.filePanels.actions"); + return ( + + + + + + + + + ); +} + +function useOpenWithMenu( + choice: FilePanelChoice | null, + testIDBase: string, +): FilePanelOpenWithMenu { + const { t } = useTranslation(); + const { openWith } = useFilePanelChoiceSnapshot(choice); + return useMemo(() => { + if (!choice || !openWith) return { trigger: null, pages: NO_PAGES }; + const title = t("panels.file.filePanels.openWith"); + return { + trigger: ( + + {title} + + ), + pages: [ + { + id: OPEN_WITH_PAGE_ID, + title, + content: ( + <> + {openWith.candidates.map((panel) => ( + + ))} + + + + ), + }, + ], + }; + }, [choice, openWith, t, testIDBase]); +} + +function OpenWithItem({ + choice, + panel, + label, + selected, + testID, +}: { + choice: FilePanelChoice; + panel: FilePanel; + label: string; + selected: boolean; + testID: string; +}) { + const handleSelect = useCallback(() => choice.openWith(panel.id), [choice, panel.id]); + const leading = useMemo( + () => ( + + ), + [panel], + ); + return ( + + {label} + + ); +} + +function AlwaysUseItem({ + choice, + label, + selected, + testID, +}: { + choice: FilePanelChoice; + label: string; + selected: boolean; + testID: string; +}) { + const handleSelect = useCallback(() => choice.toggleAlwaysUse(), [choice]); + return ( + + {label} + + ); +} diff --git a/packages/app/src/file-panels/choice/picker.tsx b/packages/app/src/file-panels/choice/picker.tsx new file mode 100644 index 0000000000..61d5aba8b6 --- /dev/null +++ b/packages/app/src/file-panels/choice/picker.tsx @@ -0,0 +1,233 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check } from "lucide-react-native"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { PluginIconView } from "@/plugins/icons"; +import { settingsStyles } from "@/styles/settings"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import type { FilePanelPickerState } from "./model"; +import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook"; +import { filePanelIcon, filePanelOptionLabel, filePanelPickerTitle } from "./presentation"; + +const ThemedCheck = withUnistyles(Check); +const ThemedPanelIcon = withUnistyles(PluginIconView); +const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The first-open question for a file with several panels, over the panel already showing. */ +export function FilePanelPicker({ choice }: { choice: FilePanelChoice }) { + const { t } = useTranslation(); + const { picker } = useFilePanelChoiceSnapshot(choice); + // The sheet animates out after the model has closed the picker; keep drawing what it asked. + const [shown, setShown] = useState(picker); + if (picker && picker !== shown) setShown(picker); + const content = picker ?? shown; + + const dismiss = useCallback(() => choice.dismissPicker(), [choice]); + const confirm = useCallback(() => choice.confirmPicker(), [choice]); + const toggleRemember = useCallback( + () => choice.setRemember(!choice.getSnapshot().picker?.remember), + [choice], + ); + const header = useMemo( + () => ({ title: content ? filePanelPickerTitle(t, content.selectorKey) : "" }), + [content, t], + ); + const footer = useMemo( + () => ( + + ), + [confirm, t], + ); + + return ( + + {content ? ( + + ) : null} + + ); +} + +const PICKER_SNAP_POINTS = ["60%", "90%"]; + +function PickerBody({ + choice, + picker, + onToggleRemember, +}: { + choice: FilePanelChoice; + picker: FilePanelPickerState; + onToggleRemember: () => void; +}) { + const { t } = useTranslation(); + const select = useCallback((panelId: string) => choice.selectInPicker(panelId), [choice]); + return ( + <> + + {picker.candidates.map((panel, index) => ( + 0} + onSelect={select} + /> + ))} + + + + ); +} + +function PanelRadioRow({ + panel, + label, + selected, + withBorder, + onSelect, +}: { + panel: FilePanel; + label: string; + selected: boolean; + withBorder: boolean; + onSelect: (panelId: string) => void; +}) { + const handlePress = useCallback(() => onSelect(panel.id), [onSelect, panel.id]); + const accessibilityState = useMemo(() => ({ checked: selected }), [selected]); + const rowStyle = useMemo( + () => [settingsStyles.row, withBorder ? settingsStyles.rowBorder : null, styles.optionRow], + [withBorder], + ); + return ( + + + {selected ? : null} + + + + {label} + + + ); +} + +function CheckboxRow({ + label, + checked, + onToggle, +}: { + label: string; + checked: boolean; + onToggle: () => void; +}) { + const accessibilityState = useMemo(() => ({ checked }), [checked]); + return ( + + + {checked ? : null} + + {label} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + footerButton: { + flex: 1, + }, + optionRow: { + justifyContent: "flex-start", + gap: theme.spacing[3], + }, + radio: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + radioSelected: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, + radioDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + }, + checkboxRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + }, + checkbox: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + checkboxChecked: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.accent, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, +})); diff --git a/packages/app/src/file-panels/choice/presentation.ts b/packages/app/src/file-panels/choice/presentation.ts new file mode 100644 index 0000000000..9c0de9170b --- /dev/null +++ b/packages/app/src/file-panels/choice/presentation.ts @@ -0,0 +1,52 @@ +import type { TFunction } from "i18next"; +import type { PluginIcon } from "@getpaseo/plugin/client"; +import type { FilePanel } from "../panels"; +import { parseFilePanelSelectorKey } from "../resolve"; + +const BUILTIN_PRESENTATION = { + "builtin:markdown": { title: "markdown", icon: "FileText" }, + "builtin:html": { title: "html", icon: "FileCode" }, + "builtin:image": { title: "image", icon: "Image" }, + "builtin:source": { title: "source", icon: "Code" }, +} as const; + +export function filePanelIcon(panel: FilePanel): PluginIcon { + return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon; +} + +/** "Markdown", "Rich Markdown": the panel switcher's label. */ +export function filePanelTitle(t: TFunction, panel: FilePanel): string { + return panel.builtin + ? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`) + : panel.contribution.title; +} + +/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */ +export function filePanelOptionLabel(t: TFunction, panel: FilePanel): string { + return t("panels.file.filePanels.option", { + title: filePanelTitle(t, panel), + owner: panel.builtin ? t("panels.file.filePanels.builtin.owner") : panel.plugin.id, + }); +} + +/** ".md" for an extension key, "Dockerfile" for a filename key. */ +export function filePanelSelectorLabel(selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" ? selector.value : `.${selector.value}`; +} + +/** "Open .md files with" for an extension key, "Open Dockerfile with" for a filename key. */ +export function filePanelPickerTitle(t: TFunction, selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" + ? t("panels.file.filePanels.pickerTitleFilename", { filename: selector.value }) + : t("panels.file.filePanels.pickerTitleExtension", { extension: selector.value }); +} + +/** "Always use for .md", "Always use for Dockerfile". */ +export function filePanelAlwaysUseLabel(t: TFunction, selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" + ? t("panels.file.filePanels.alwaysUseFilename", { filename: selector.value }) + : t("panels.file.filePanels.alwaysUseExtension", { extension: selector.value }); +} diff --git a/packages/app/src/file-panels/choice/registry.ts b/packages/app/src/file-panels/choice/registry.ts new file mode 100644 index 0000000000..43a73984b6 --- /dev/null +++ b/packages/app/src/file-panels/choice/registry.ts @@ -0,0 +1,42 @@ +import { + buildPanelInstanceKey, + type PanelInstanceIdentity, +} from "@/panels/panel-instance-attributes"; +import type { FilePanel } from "../panels"; +import type { FilePanelChoiceModel } from "./model"; + +/** + * The open file tabs' choice models, so tab chrome outside the pane (the tab's context menu) + * reads and drives the same choice the pane renders. + */ +const models = new Map>(); +const listeners = new Set<() => void>(); + +export function registerFilePanelChoice( + identity: PanelInstanceIdentity, + model: FilePanelChoiceModel, +): () => void { + const key = buildPanelInstanceKey(identity); + models.set(key, model); + notify(); + return () => { + if (models.get(key) !== model) return; + models.delete(key); + notify(); + }; +} + +export function getFilePanelChoice( + identity: PanelInstanceIdentity, +): FilePanelChoiceModel | null { + return models.get(buildPanelInstanceKey(identity)) ?? null; +} + +export function subscribeFilePanelChoices(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notify(): void { + for (const listener of listeners) listener(); +} diff --git a/packages/app/src/file-panels/choice/switcher-layout.test.ts b/packages/app/src/file-panels/choice/switcher-layout.test.ts new file mode 100644 index 0000000000..196f03bf17 --- /dev/null +++ b/packages/app/src/file-panels/choice/switcher-layout.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { FilePanelOpenWithState } from "./model"; +import { filePanelSwitcherState } from "./switcher-layout"; + +function openWith(count: number): FilePanelOpenWithState<{ id: string }> { + const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` })); + return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false }; +} + +describe("filePanelSwitcherState", () => { + it("shows nothing when the file has one panel or none", () => { + expect(filePanelSwitcherState(null, { compact: false })).toBeNull(); + }); + + it("sets up to three panels side by side and more behind a menu", () => { + const wide = { compact: false }; + expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented"); + expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented"); + expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({ + layout: "menu", + activePanelId: "panel-1", + candidates: openWith(4).candidates, + }); + }); + + it("sets two panels side by side on a compact screen and more behind a menu", () => { + const compact = { compact: true }; + expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented"); + expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu"); + }); +}); diff --git a/packages/app/src/file-panels/choice/switcher-layout.ts b/packages/app/src/file-panels/choice/switcher-layout.ts new file mode 100644 index 0000000000..16afae0fe9 --- /dev/null +++ b/packages/app/src/file-panels/choice/switcher-layout.ts @@ -0,0 +1,24 @@ +import type { FilePanelOpenWithState } from "./model"; + +/** Up to this many panels sit side by side in the bar; more go behind a menu. */ +const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 }; + +export interface FilePanelSwitcherState { + layout: "segmented" | "menu"; + candidates: readonly Candidate[]; + activePanelId: string; +} + +/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */ +export function filePanelSwitcherState( + openWith: FilePanelOpenWithState | null, + { compact }: { compact: boolean }, +): FilePanelSwitcherState | null { + if (!openWith) return null; + const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide; + return { + layout: openWith.candidates.length > limit ? "menu" : "segmented", + candidates: openWith.candidates, + activePanelId: openWith.activePanelId, + }; +} diff --git a/packages/app/src/file-panels/choice/switcher.tsx b/packages/app/src/file-panels/choice/switcher.tsx new file mode 100644 index 0000000000..75aef62727 --- /dev/null +++ b/packages/app/src/file-panels/choice/switcher.tsx @@ -0,0 +1,138 @@ +import { useCallback, useMemo } from "react"; +import { Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import { ChevronDown } from "lucide-react-native"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { MenuItem } from "@/components/ui/menu"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { + ToolbarLabelTriggerIcon, + isToolbarLabelTriggerHighlighted, + toolbarLabelTriggerStyle, + toolbarLabelTriggerTextStyle, +} from "@/components/ui/toolbar-label-trigger"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { PluginIconView } from "@/plugins/icons"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook"; +import { filePanelIcon, filePanelTitle } from "./presentation"; +import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout"; + +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedPanelIcon = withUnistyles(PluginIconView); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */ +export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) { + const { openWith } = useFilePanelChoiceSnapshot(choice); + const compact = useIsCompactFormFactor(); + const switcher = filePanelSwitcherState(openWith, { compact }); + const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]); + if (!switcher) return null; + return switcher.layout === "segmented" ? ( + + ) : ( + + ); +} + +interface SwitcherProps { + switcher: FilePanelSwitcherState; + onSelect(panelId: string): void; +} + +function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) { + const { t } = useTranslation(); + const options = useMemo( + () => + switcher.candidates.map((panel) => ({ + value: panel.id, + label: filePanelTitle(t, panel), + testID: `file-panel-switcher-${panel.id}`, + })), + [switcher.candidates, t], + ); + return ( + + ); +} + +function MenuSwitcher({ switcher, onSelect }: SwitcherProps) { + const { t } = useTranslation(); + const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId); + const activeTitle = active ? filePanelTitle(t, active) : ""; + return ( + + + {(state) => ( + <> + + {activeTitle} + + + + + + )} + + + {switcher.candidates.map((panel) => ( + + ))} + + + ); +} + +function SwitcherMenuItem({ + panel, + selected, + onSelect, +}: { + panel: FilePanel; + selected: boolean; + onSelect(panelId: string): void; +}) { + const { t } = useTranslation(); + const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]); + const leading = useMemo( + () => , + [panel], + ); + return ( + + {filePanelTitle(t, panel)} + + ); +} diff --git a/packages/app/src/file-panels/document-frame/index.tsx b/packages/app/src/file-panels/document-frame/index.tsx new file mode 100644 index 0000000000..c931ef5f08 --- /dev/null +++ b/packages/app/src/file-panels/document-frame/index.tsx @@ -0,0 +1,98 @@ +import { useCallback, useMemo, useRef } from "react"; +import { Platform, Text, View } from "react-native"; +import { WebView } from "react-native-webview"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; + +const frameStyle = { flex: 1, backgroundColor: "white" }; +const originWhitelist = ["*"]; + +/** What Android's WebView shows inline. It has no viewer for PDF or other documents. */ +const ANDROID_INLINE_EXTENSIONS = new Set([ + "html", + "htm", + "xhtml", + "xml", + "svg", + "txt", + "json", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "avif", +]); + +export function DocumentFrame({ url, testID }: { url: string; testID?: string }) { + const source = useMemo(() => ({ uri: url }), [url]); + const loaded = useRef(null); + const allowDocument = useCallback( + (request: { url: string }) => { + if (!url.startsWith("file:") || request.url !== url || loaded.current === url) return false; + loaded.current = url; + return true; + }, + [url], + ); + if (!url.startsWith("file:")) return null; + // The host names a file URL after the file, so its extension is the document's type. + const extension = urlExtension(url); + if (Platform.OS === "android" && !ANDROID_INLINE_EXTENSIONS.has(extension)) { + return ; + } + return ( + + ); +} + +function UnsupportedDocument({ extension, testID }: { extension: string; testID?: string }) { + const { t } = useTranslation(); + return ( + + + {t("panels.file.documentUnsupported", { type: extension.toUpperCase() })} + + + ); +} + +function urlExtension(url: string): string { + const path = url.split(/[?#]/)[0]; + const name = path.slice(path.lastIndexOf("/") + 1); + const dot = name.lastIndexOf("."); + return dot < 0 ? "" : name.slice(dot + 1).toLowerCase(); +} + +const styles = StyleSheet.create((theme) => ({ + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + backgroundColor: theme.colors.surface0, + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-panels/document-frame/index.web.tsx b/packages/app/src/file-panels/document-frame/index.web.tsx new file mode 100644 index 0000000000..a4cf9ac83d --- /dev/null +++ b/packages/app/src/file-panels/document-frame/index.web.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { documentFrameSandbox } from "./sandbox"; + +const frameStyle = { flex: 1, minHeight: 0, border: "none", backgroundColor: "white" } as const; + +interface ResolvedDocument { + url: string; + sandbox: string | null; +} + +export function DocumentFrame({ url, testID }: { url: string; testID?: string }) { + const [document, setDocument] = useState(null); + + useEffect(() => { + if (!url.startsWith("blob:")) return; + const lifetime = new AbortController(); + void fetch(url, { signal: lifetime.signal }) + .then(async (response) => { + const sandbox = documentFrameSandbox(response.headers.get("content-type")); + await response.body?.cancel(); + if (!lifetime.signal.aborted) setDocument({ url, sandbox }); + return undefined; + }) + .catch(() => { + if (!lifetime.signal.aborted) setDocument({ url, sandbox: "" }); + }); + return () => lifetime.abort(); + }, [url]); + + if (!url.startsWith("blob:") || document?.url !== url) return null; + return ( +