@av-pi-studio/web-client 0.0.67 → 0.0.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/assets/{BinaryFallbackViewer-CcuvrutG.js → BinaryFallbackViewer-B4r944o6.js} +2 -2
- package/dist/web/assets/{BinaryFallbackViewer-CcuvrutG.js.map → BinaryFallbackViewer-B4r944o6.js.map} +1 -1
- package/dist/web/assets/{ChatPanel-DgCKLvEc.js → ChatPanel-BOntrcr9.js} +2 -2
- package/dist/web/assets/{ChatPanel-DgCKLvEc.js.map → ChatPanel-BOntrcr9.js.map} +1 -1
- package/dist/web/assets/{CodeView-CXE9vlF1.js → CodeView-D_odcRcG.js} +2 -2
- package/dist/web/assets/{CodeView-CXE9vlF1.js.map → CodeView-D_odcRcG.js.map} +1 -1
- package/dist/web/assets/{FilePanel-jN6FYiGo.js → FilePanel-CBHtXzH8.js} +2 -2
- package/dist/web/assets/{FilePanel-jN6FYiGo.js.map → FilePanel-CBHtXzH8.js.map} +1 -1
- package/dist/web/assets/{ImageViewer-DdvsjbtG.js → ImageViewer-DPZ7_PaD.js} +2 -2
- package/dist/web/assets/{ImageViewer-DdvsjbtG.js.map → ImageViewer-DPZ7_PaD.js.map} +1 -1
- package/dist/web/assets/{MarkdownFileViewer-BNG2in_C.js → MarkdownFileViewer-BYlX5yMS.js} +2 -2
- package/dist/web/assets/{MarkdownFileViewer-BNG2in_C.js.map → MarkdownFileViewer-BYlX5yMS.js.map} +1 -1
- package/dist/web/assets/MoleculeViewerPanel-DRa2MkzE.js +2 -0
- package/dist/web/assets/MoleculeViewerPanel-DRa2MkzE.js.map +1 -0
- package/dist/web/assets/{TerminalPanel-zJX45luf.js → TerminalPanel-C9LkL3-o.js} +2 -2
- package/dist/web/assets/{TerminalPanel-zJX45luf.js.map → TerminalPanel-C9LkL3-o.js.map} +1 -1
- package/dist/web/assets/{TextViewer-DLce2RZp.js → TextViewer-Bozyr3Os.js} +2 -2
- package/dist/web/assets/{TextViewer-DLce2RZp.js.map → TextViewer-Bozyr3Os.js.map} +1 -1
- package/dist/web/assets/{VideoViewer-I4xBhKGc.js → VideoViewer-CJvkrEyO.js} +2 -2
- package/dist/web/assets/{VideoViewer-I4xBhKGc.js.map → VideoViewer-CJvkrEyO.js.map} +1 -1
- package/dist/web/assets/{index-KQwi4ZXj.js → index-IJCh_DKC.js} +3 -3
- package/dist/web/assets/{index-KQwi4ZXj.js.map → index-IJCh_DKC.js.map} +1 -1
- package/dist/web/assets/{markdown-CfR91Pqp.js → markdown-lrvXpy4g.js} +2 -2
- package/dist/web/assets/{markdown-CfR91Pqp.js.map → markdown-lrvXpy4g.js.map} +1 -1
- package/dist/web/assets/{use-file-download-Dv5HRkkB.js → use-file-download-DLJldTqH.js} +2 -2
- package/dist/web/assets/{use-file-download-Dv5HRkkB.js.map → use-file-download-DLJldTqH.js.map} +1 -1
- package/dist/web/assets/{use-file-watch-DTOtoP8W.js → use-file-watch-B-hlDpYI.js} +2 -2
- package/dist/web/assets/{use-file-watch-DTOtoP8W.js.map → use-file-watch-B-hlDpYI.js.map} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +3 -3
- package/dist/web/assets/MoleculeViewerPanel-CQQ9CIsD.js +0 -2
- package/dist/web/assets/MoleculeViewerPanel-CQQ9CIsD.js.map +0 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as d,j as s}from"./vendor-react-CWo7Do4s.js";import{c as S}from"./vendor-highlight-Efc6A-g6.js";import{n as w,c as E,k as C,d as M,a as k,l as H,m as T,o as y,b as p,S as F,p as x,q as I}from"./index-
|
|
2
|
-
//# sourceMappingURL=markdown-
|
|
1
|
+
import{r as d,j as s}from"./vendor-react-CWo7Do4s.js";import{c as S}from"./vendor-highlight-Efc6A-g6.js";import{n as w,c as E,k as C,d as M,a as k,l as H,m as T,o as y,b as p,S as F,p as x,q as I}from"./index-IJCh_DKC.js";import{M as D,r as z}from"./vendor-markdown-YvkQLm_s.js";const b="github-dark";let f=null;const h=new Set(["plaintext"]);function L(){return f||(f=S({themes:[b],langs:["plaintext"]})),f}async function N(n,e){if(h.has(e))return e;try{return await n.loadLanguage(e),h.add(e),e}catch{return"plaintext"}}async function R(n,e){const t=await L(),r=await N(t,e);return t.codeToHtml(n,{lang:r,theme:b})}const U="_prose_1cnx5_1",P="_inlineCode_1cnx5_40",A="_inlineImage_1cnx5_66",W="_inlineImageSkeleton_1cnx5_75",Z="_inlineImageFallback_1cnx5_86",u={prose:U,inlineCode:P,inlineImage:A,inlineImageSkeleton:W,inlineImageFallback:Z},$=/^([a-zA-Z][a-zA-Z0-9+.-]*):/;function q(n){try{return decodeURIComponent(n)}catch{return n}}function j(n,e,t){const r=n.trim();if(!r)return null;const a=r.split(/[?#]/,1)[0];if(!a||$.exec(a))return null;let l;if(a.startsWith("/")?l=a:a==="~"||a.startsWith("~/")?l=t?w(a,t):null:l=E(a,e??""),!l)return null;const c=C(l);return q(c)}const O={"http:":!0,"https:":!0,"data:":!0,"blob:":!0},V=/^([a-zA-Z][a-zA-Z0-9+.-]*):/;function G(n,e,t){const r=n.trim();if(!r)return{kind:"unresolvable"};const a=r.split(/[?#]/,1)[0];if(!a)return{kind:"unresolvable"};const i=V.exec(a);if(i)return`${i[1].toLowerCase()}:`in O?{kind:"remote"}:{kind:"unresolvable"};const l=j(n,e,t);return l?M(l)!=="image"?{kind:"unresolvable"}:{kind:"local",path:l}:{kind:"unresolvable"}}function K(n,e,t,r){return n.kind==="remote"?{kind:"remote",src:t||"",alt:r}:n.kind==="unresolvable"?{kind:"unresolvable",fallbackText:r||t||"(image)"}:e.status==="loading"||e.status==="idle"?{kind:"loading"}:e.status==="ready"?{kind:"ready",objectUrl:e.objectUrl,alt:r,path:n.path}:{kind:"error",fallbackText:r||n.path||"(image)"}}function _(n,e,t){return{workspaceCwd:t??(n||"~"),targetPaneId:e??void 0}}function J(n,e,t){if(!n||!e){t({status:"idle"});return}let r=!1;return t({status:"loading"}),H(n,e).then(a=>{r||t({status:"ready",objectUrl:a.objectUrl})}).catch(a=>{r||t({status:"error",message:a instanceof Error?a.message:String(a)})}),()=>{r=!0,T(n)}}function Q(n){const e=k(i=>i.client),t=k(i=>i.daemon),[r,a]=d.useState({status:"idle"});return d.useEffect(()=>J(n,e&&t?l=>y(t).download(l):null,a),[e,t,n]),r}const X=d.memo(function({src:e,alt:t,assetBase:r,owningPaneId:a,workspaceCwd:i}){const l=p(),c=d.useMemo(()=>G(e||"",r,l),[e,r,l]),m=Q(c.kind==="local"?c.path:null),o=K(c,m,e,t);if(o.kind==="remote")return s.jsx("img",{src:o.src,alt:o.alt,className:u.inlineImage});if(o.kind==="unresolvable"||o.kind==="error")return s.jsx("code",{className:u.inlineImageFallback,children:o.fallbackText});if(o.kind==="loading")return s.jsx("div",{className:u.inlineImageSkeleton,children:s.jsx(F,{size:"sm"})});const g=_(r,a??null,i??null),v=()=>{I(o.path,g.workspaceCwd,g.targetPaneId)};return s.jsx("img",{src:o.objectUrl,alt:o.alt,className:u.inlineImage,title:o.path,draggable:!0,onDragStart:x(o.path),onClick:v,style:{cursor:"pointer"}})});function Y(n,e,t){const r=n.trim();if(!r)return{kind:"external"};if(r.startsWith("#"))return{kind:"external"};const a=j(n,e,t);return a?{kind:"local",path:a}:{kind:"external"}}function B({href:n,children:e,assetBase:t,owningPaneId:r,workspaceCwd:a}){const i=p(),l=Y(n??"",t,i);if(l.kind==="external")return s.jsx("a",{href:n,children:e});const c=l.path,m=_(t,r,a);return s.jsx("a",{href:n,draggable:!0,onDragStart:x(c),onClick:o=>{o.preventDefault(),I(c,m.workspaceCwd,m.targetPaneId)},children:e})}function ee({language:n,code:e}){const[t,r]=d.useState(null);return d.useEffect(()=>{let a=!1;return R(e,n).then(i=>{a||r(i)}),()=>{a=!0}},[e,n]),t?s.jsx("div",{className:u.prose,dangerouslySetInnerHTML:{__html:t}}):s.jsx("pre",{children:s.jsx("code",{children:e})})}function ne({className:n,children:e,...t}){const r=/language-(\w+)/.exec(n??""),a=!!r,i=String(e).replace(/\n$/,"");return a&&r?s.jsx(ee,{language:r[1]??"plaintext",code:i}):s.jsx("code",{className:u.inlineCode,...t,children:e})}const le=d.memo(function({text:e,assetBase:t=null,owningPaneId:r=null,workspaceCwd:a=null}){return s.jsx("div",{className:u.prose,children:s.jsx(D,{remarkPlugins:[z],components:{code:ne,img:i=>s.jsx(X,{...i,assetBase:t,owningPaneId:r,workspaceCwd:a}),a:i=>s.jsx(B,{...i,assetBase:t,owningPaneId:r,workspaceCwd:a})},children:e})})});export{le as M};
|
|
2
|
+
//# sourceMappingURL=markdown-lrvXpy4g.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown-CfR91Pqp.js","sources":["../../../src/timeline/highlight.ts","../../../src/timeline/href-resolution.ts","../../../src/timeline/image-src.ts","../../../src/timeline/inline-image-view.ts","../../../src/timeline/file-open-target.ts","../../../src/hooks/use-inline-image.ts","../../../src/timeline/InlineImage.tsx","../../../src/timeline/file-link-src.ts","../../../src/timeline/FileLink.tsx","../../../src/timeline/markdown.tsx"],"sourcesContent":["/**\n * Shiki wrapper + lang-from-path map. Replaces the POC's CDN `highlight.js`\n * (POC_TO_APP_PLAN_UI.md §2, §4.8). Grammars/themes are loaded lazily and cached — the\n * highlighter is not paid for until a code block/file view actually renders.\n */\n\nimport { createHighlighter, type Highlighter } from \"shiki\";\n\n/** POC `getLangFromPath` extension → language map, ported verbatim. */\nconst EXT_TO_LANG: Record<string, string> = {\n js: \"javascript\",\n ts: \"typescript\",\n jsx: \"javascript\",\n tsx: \"typescript\",\n py: \"python\",\n rb: \"ruby\",\n rs: \"rust\",\n go: \"go\",\n java: \"java\",\n c: \"c\",\n cpp: \"cpp\",\n h: \"c\",\n hpp: \"cpp\",\n cs: \"csharp\",\n html: \"html\",\n css: \"css\",\n scss: \"scss\",\n json: \"json\",\n yaml: \"yaml\",\n yml: \"yaml\",\n toml: \"toml\",\n xml: \"xml\",\n md: \"markdown\",\n sh: \"bash\",\n bash: \"bash\",\n sql: \"sql\",\n swift: \"swift\",\n kt: \"kotlin\",\n dart: \"dart\",\n vue: \"xml\",\n svelte: \"xml\",\n};\n\nexport function langFromPath(path: string): string {\n const ext = path.split(\".\").pop()?.toLowerCase() ?? \"\";\n return EXT_TO_LANG[ext] ?? \"plaintext\";\n}\n\nconst THEME = \"github-dark\";\n\nlet highlighterPromise: Promise<Highlighter> | null = null;\nconst loadedLangs = new Set<string>([\"plaintext\"]);\n\nfunction getHighlighter(): Promise<Highlighter> {\n if (!highlighterPromise) {\n highlighterPromise = createHighlighter({ themes: [THEME], langs: [\"plaintext\"] });\n }\n return highlighterPromise;\n}\n\nasync function ensureLang(highlighter: Highlighter, lang: string): Promise<string> {\n if (loadedLangs.has(lang)) return lang;\n try {\n await highlighter.loadLanguage(lang as never);\n loadedLangs.add(lang);\n return lang;\n } catch {\n return \"plaintext\"; // unknown grammar to Shiki — fall back rather than throw\n }\n}\n\n/** Highlight `code` for `lang` (or a path via `langFromPath`) to sanitized HTML. */\nexport async function highlightToHtml(code: string, lang: string): Promise<string> {\n const highlighter = await getHighlighter();\n const resolved = await ensureLang(highlighter, lang);\n return highlighter.codeToHtml(code, { lang: resolved, theme: THEME });\n}\n","/**\n * Shared href/src candidate resolution — used by both classifyImageSrc and classifyFileLinkSrc.\n *\n * Handles the common steps: strip fragments/query, detect schemes, expand ~/relative paths,\n * normalize, and percent-decode. Returns a resolved local candidate (absolute, normalized,\n * percent-decoded) or null if external/unresolvable.\n */\n\nimport { normalizeCwd } from \"@pi-studio-ui/features/sessions/workspace-grouping.js\";\nimport { collapseDotSegments, resolveWorkspacePath } from \"@pi-studio-ui/lib/paths.js\";\n\n/** Matches a leading `scheme:` (e.g. `http:`, `file:`, `mailto:`) — not a Windows drive letter\n * (`C:\\...`) or a bare `:` with no scheme name. */\nconst SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/;\n\n/**\n * Try to percent-decode a string using decodeURIComponent. On malformed sequences (e.g. `%E0%`),\n * return the original string unchanged rather than throwing.\n */\nfunction percentDecode(str: string): string {\n try {\n return decodeURIComponent(str);\n } catch {\n // Malformed percent sequence; return unchanged\n return str;\n }\n}\n\n/**\n * Shared resolution step for markdown hrefs/srcs. Strips fragments/query, detects schemes,\n * expands tilde and relatives, normalizes and percent-decodes the result.\n *\n * Returns a resolved local candidate (absolute path, normalized, percent-decoded) or null\n * if the input is external (has a scheme) or unresolvable (no base for a relative path, etc).\n *\n * @param href The raw markdown href/src\n * @param base The asset base (absolute, tilde-normalized) or null\n * @param homeDir The home directory or null\n * @returns The resolved candidate or null\n */\nexport function resolveHrefCandidate(\n href: string,\n base: string | null,\n homeDir: string | null,\n): string | null {\n const trimmed = href.trim();\n if (!trimmed) return null;\n\n // Strip `#fragment` and `?query` before classifying\n const stripped = trimmed.split(/[?#]/, 1)[0]!;\n if (!stripped) return null;\n\n // Detect explicit schemes (http:, file:, etc). If found, it's external — return null.\n const schemeMatch = SCHEME_RE.exec(stripped);\n if (schemeMatch) {\n return null; // Has a scheme; external\n }\n\n // Resolve the candidate: absolute, tilde-prefixed, or relative\n let candidate: string | null;\n if (stripped.startsWith(\"/\")) {\n candidate = stripped;\n } else if (stripped === \"~\" || stripped.startsWith(\"~/\")) {\n candidate = homeDir ? normalizeCwd(stripped, homeDir) : null;\n } else {\n // Relative path\n candidate = resolveWorkspacePath(stripped, base ?? \"\");\n }\n\n if (!candidate) return null;\n\n // Normalize (collapse ./ and ../ segments) and percent-decode\n const normalized = collapseDotSegments(candidate);\n return percentDecode(normalized);\n}\n","/**\n * Markdown image source classification (features/inline-image-rendering.md § Public Contract →\n * Markdown image source classification, § Asset base). The single gate every `` in a\n * rendered chat message passes through before any network request is even considered — deciding\n * remote (render directly), local (fetch via the daemon's file-transfer path, task-003), or\n * unresolvable (render as plain fallback text, never attempt a request).\n *\n * Pure: no store/hook/network imports, so this file needs no React or client mocks to test.\n */\n\nimport { detectViewerKind } from \"@pi-studio-ui/features/files/viewer-registry.js\";\nimport { resolveHrefCandidate } from \"./href-resolution.js\";\n\nexport type ImageSrcClassification =\n | { kind: \"remote\" }\n | { kind: \"local\"; path: string }\n | { kind: \"unresolvable\" };\n\nconst REMOTE_SCHEMES: Record<string, true> = {\n \"http:\": true,\n \"https:\": true,\n \"data:\": true,\n \"blob:\": true,\n};\n\n/** Matches a leading `scheme:` (e.g. `http:`, `file:`, `mailto:`) — not a Windows drive letter\n * (`C:\\...`) or a bare `:` with no scheme name. */\nconst SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/;\n\n/**\n * Classifies a markdown image `src`. `base` is the asset base (absolute, tilde-normalized) or\n * `null` when unknown; `homeDir` may be `null` when the daemon's home directory hasn't been\n * learned yet. Order matters — this mirrors the scope's classification table exactly:\n *\n * 1. empty/whitespace → unresolvable\n * 2. `http:`/`https:`/`data:`/`blob:` → remote\n * 3. any other `scheme:` prefix (including `file:`) → unresolvable\n * 4. `/…` → candidate local path as-is\n * 5. `~`/`~/…` → expand against `homeDir` (unknown home dir → unresolvable)\n * 6. `./…`, `../…`, bare relative → resolve against `base` (no base → unresolvable)\n * 7. final gate: the candidate must be an image per `detectViewerKind`, else unresolvable\n */\nexport function classifyImageSrc(\n src: string,\n base: string | null,\n homeDir: string | null,\n): ImageSrcClassification {\n const trimmed = src.trim();\n if (!trimmed) return { kind: \"unresolvable\" };\n\n // Markdown may carry a title/version via `#fragment`/`?query`; strip both before classifying so\n // a trailing `?` never defeats the extension gate below.\n const stripped = trimmed.split(/[?#]/, 1)[0]!;\n if (!stripped) return { kind: \"unresolvable\" };\n\n const schemeMatch = SCHEME_RE.exec(stripped);\n if (schemeMatch) {\n const scheme = `${schemeMatch[1]!.toLowerCase()}:`;\n return scheme in REMOTE_SCHEMES ? { kind: \"remote\" } : { kind: \"unresolvable\" };\n }\n\n // Use the shared resolution step: resolves path, applies normalization + percent-decoding\n const candidate = resolveHrefCandidate(src, base, homeDir);\n if (!candidate) return { kind: \"unresolvable\" };\n\n // Image-specific gate: must be an image extension\n if (detectViewerKind(candidate) !== \"image\") return { kind: \"unresolvable\" };\n return { kind: \"local\", path: candidate };\n}\n","/**\n * Pure, testable logic for rendering inline images — extracted from the component\n * so tests can verify branch logic without JSX rendering (mirror of molecule-reload.ts,\n * text-viewer-state.ts; see AGENTS.md testing conventions).\n *\n * Returns a discriminated union describing what to render: the component switches\n * on it to decide remote passthrough, skeleton, ready image, or text fallback.\n */\n\nimport type { ImageSrcClassification } from \"./image-src.js\";\nimport type { InlineImageState } from \"@pi-studio-ui/hooks/use-inline-image.js\";\n\nexport type InlineImageView =\n | { kind: \"remote\"; src: string; alt?: string }\n | { kind: \"unresolvable\"; fallbackText: string }\n | { kind: \"loading\" }\n | { kind: \"ready\"; objectUrl: string; alt?: string; path: string }\n | { kind: \"error\"; fallbackText: string };\n\n/**\n * Decides what to render for an inline image given its classification and loading state.\n * Pure: no hooks, no store access — all state passed in as arguments.\n */\nexport function selectInlineImageView(\n classification: ImageSrcClassification,\n imageState: InlineImageState,\n src: string | undefined,\n alt: string | undefined,\n): InlineImageView {\n if (classification.kind === \"remote\") {\n return { kind: \"remote\", src: src || \"\", alt };\n }\n\n if (classification.kind === \"unresolvable\") {\n const fallbackText = alt || src || \"(image)\";\n return { kind: \"unresolvable\", fallbackText };\n }\n\n // classification.kind === \"local\"\n if (imageState.status === \"loading\" || imageState.status === \"idle\") {\n return { kind: \"loading\" };\n }\n\n if (imageState.status === \"ready\") {\n return { kind: \"ready\", objectUrl: imageState.objectUrl, alt, path: classification.path };\n }\n\n // imageState.status === \"error\"\n const fallbackText = alt || classification.path || \"(image)\";\n return { kind: \"error\", fallbackText };\n}\n","/**\n * Resolves the `openFileTab` workspace-cwd/target-pane arguments shared by `FileLink` and\n * `InlineImage`'s converged click-to-open dispatch (features/file-link-rendering.md §\n * Click-to-open pane targeting, features/inline-image-rendering.md § Click-to-open, amended).\n *\n * The owning chat tab's real `workspaceCwd` always wins — `assetBase || \"~\"` is only a fallback\n * for markdown surfaces rendered outside any tab, where no owning tab exists to name; the chat\n * render path always threads a real `workspaceCwd` (task-002) and never hits the fallback.\n * `owningPaneId`'s `null` (no owning pane yet known, e.g. a tab not yet placed by restore)\n * converts to `undefined`, matching `openFileTab`'s `targetPaneId: string | undefined` contract —\n * `undefined` falls back to whichever pane is currently focused.\n *\n * Pure: no store/hook/network imports, so this file needs no React or client mocks to test.\n */\nexport function resolveFileOpenTarget(\n assetBase: string | null,\n owningPaneId: string | null,\n workspaceCwd: string | null,\n): { workspaceCwd: string; targetPaneId: string | undefined } {\n return {\n workspaceCwd: workspaceCwd ?? (assetBase || \"~\"),\n targetPaneId: owningPaneId ?? undefined,\n };\n}\n","/**\n * `use-inline-image` — fetches (via the shared ref-counted `inline-image-cache`) and hands back\n * an object URL for an inline chat image (features/inline-image-rendering.md § Inline image fetch\n * + cache). Consumed by the markdown `img` node override (task-004) once a `classifyImageSrc`\n * result is `local`.\n *\n * Deliberately NOT a TanStack Query hook, and therefore no `rpcKeys` entry: the whole point of\n * `inline-image-cache.ts` is a retention policy (revoke only on LRU eviction, never on unmount)\n * that Query's cache cannot express. Do not \"fix\" this into a `useQuery` — see that module's\n * header for why.\n *\n * `loadInlineImage` is the actual effect body, extracted for direct unit testing without\n * rendering — this package has no jsdom/React-Testing-Library environment (`AGENTS.md`'s testing\n * convention; mirrors `use-file-watch.ts`'s `watchFile` extraction).\n */\n\nimport { useEffect, useState } from \"react\";\nimport { useConnectionStore } from \"@pi-studio-ui/lib/connection/connection-store.js\";\nimport { acquireInlineImage, releaseInlineImage } from \"@pi-studio-ui/lib/inline-image-cache.js\";\nimport { transferFor } from \"./file-transfer-instance.js\";\n\nexport type InlineImageState =\n | { status: \"idle\" } // no client yet, or path is null\n | { status: \"loading\" }\n | { status: \"ready\"; objectUrl: string }\n | { status: \"error\"; message: string };\n\n/**\n * Runs one effect cycle: `download === null` (no live connection, or no path) is the idle case —\n * nothing to acquire, nothing to clean up. Otherwise acquires `path`, reports the loading →\n * ready/error transition via `onStateChange`, and returns a cleanup that releases the path and\n * ignores a resolution that lands after it (the `cancelled` flag pattern used across the existing\n * hooks, e.g. `use-checkout-status.ts`).\n */\nexport function loadInlineImage(\n path: string | null,\n download: ((path: string) => Promise<{ bytes: Uint8Array; mimeType?: string }>) | null,\n onStateChange: (state: InlineImageState) => void,\n): (() => void) | void {\n if (!path || !download) {\n onStateChange({ status: \"idle\" });\n return;\n }\n\n let cancelled = false;\n onStateChange({ status: \"loading\" });\n\n acquireInlineImage(path, download)\n .then((entry) => {\n if (!cancelled) onStateChange({ status: \"ready\", objectUrl: entry.objectUrl });\n })\n .catch((error: unknown) => {\n if (!cancelled) {\n onStateChange({\n status: \"error\",\n message: error instanceof Error ? error.message : String(error),\n });\n }\n });\n\n return () => {\n cancelled = true;\n releaseInlineImage(path);\n };\n}\n\nexport function useInlineImage(path: string | null): InlineImageState {\n const client = useConnectionStore((s) => s.client);\n const daemon = useConnectionStore((s) => s.daemon);\n const [state, setState] = useState<InlineImageState>({ status: \"idle\" });\n\n useEffect(() => {\n const download = client && daemon ? (p: string) => transferFor(daemon).download(p) : null;\n return loadInlineImage(path, download, setState);\n }, [client, daemon, path]);\n\n return state;\n}\n","/**\n * `` renderer for finalized assistant markdown (features/inline-image-rendering.md §\n * Behavior & Algorithms → Render pipeline). Handles remote passthrough, local file fetch via\n * useInlineImage (task-003), classification (task-002), and click-to-open file tab.\n *\n * Props are optional (react-markdown may omit them) and must be handled defensively.\n */\n\nimport { memo, useMemo } from \"react\";\nimport { classifyImageSrc } from \"./image-src.js\";\nimport { selectInlineImageView } from \"./inline-image-view.js\";\nimport { resolveFileOpenTarget } from \"./file-open-target.js\";\nimport { useInlineImage } from \"@pi-studio-ui/hooks/use-inline-image.js\";\nimport { useHomeDir } from \"@pi-studio-ui/hooks/use-home-dir.js\";\nimport { openFileTab } from \"@pi-studio-ui/features/files/open-file-tab.js\";\nimport { pathDragStartHandler } from \"@pi-studio-ui/features/workspace/external-drag.js\";\nimport { Spinner } from \"@pi-studio-ui/components/primitives/Spinner.js\";\nimport styles from \"./markdown.module.css\";\n\nexport interface InlineImageProps {\n src?: string;\n alt?: string;\n assetBase: string | null;\n owningPaneId?: string | null;\n workspaceCwd?: string | null;\n}\n\nexport const InlineImage = memo(function InlineImage({\n src,\n alt,\n assetBase,\n owningPaneId,\n workspaceCwd,\n}: InlineImageProps) {\n const homeDir = useHomeDir();\n\n // Classify the source once at render time.\n const classification = useMemo(\n () => classifyImageSrc(src || \"\", assetBase, homeDir),\n [src, assetBase, homeDir],\n );\n\n // For local images, fetch and track state.\n const imageState = useInlineImage(classification.kind === \"local\" ? classification.path : null);\n\n // Decide what to render.\n const view = selectInlineImageView(classification, imageState, src, alt);\n\n // Remote images: pass through unchanged (what react-markdown's default would do).\n if (view.kind === \"remote\") {\n return <img src={view.src} alt={view.alt} className={styles.inlineImage} />;\n }\n\n // Unresolvable or error: render text fallback in inline monospace.\n if (view.kind === \"unresolvable\" || view.kind === \"error\") {\n return <code className={styles.inlineImageFallback}>{view.fallbackText}</code>;\n }\n\n // Loading or idle: skeleton.\n if (view.kind === \"loading\") {\n return (\n <div className={styles.inlineImageSkeleton}>\n <Spinner size=\"sm\" />\n </div>\n );\n }\n\n // Ready: render the fetched image with click-to-open handler, converged onto the same\n // `openFileTab` dispatch the Files tree and `FileLink` use — puts the pane-targeting fix\n // (owning tab's real `workspaceCwd`, `owningPaneId`) in exactly one place for both features.\n const target = resolveFileOpenTarget(assetBase, owningPaneId ?? null, workspaceCwd ?? null);\n const handleClick = () => {\n openFileTab(view.path, target.workspaceCwd, target.targetPaneId);\n };\n\n return (\n <img\n src={view.objectUrl}\n alt={view.alt}\n className={styles.inlineImage}\n title={view.path}\n draggable\n onDragStart={pathDragStartHandler(view.path)}\n onClick={handleClick}\n style={{ cursor: \"pointer\" }}\n />\n );\n});\n","/**\n * Markdown file link source classification (features/file-link-rendering.md § Public Contract →\n * Markdown link source classification). Decides whether a markdown link `[label](href)` should\n * open a local file (intercepted by the client) or navigate as a regular link.\n *\n * Two-way classification simpler than the image classifier's three-way one:\n * - `local`: resolves to a local filesystem path → intercept, open as a file\n * - `external`: anything else → pass through as a regular navigating anchor\n *\n * Pure: no store/hook/network imports, so this file needs no React or client mocks to test.\n */\n\nimport { resolveHrefCandidate } from \"./href-resolution.js\";\n\nexport type FileLinkClassification = { kind: \"local\"; path: string } | { kind: \"external\" };\n\n/**\n * Classifies a markdown link `href`. `base` is the asset base (absolute, tilde-normalized) or\n * `null` when unknown; `homeDir` may be `null` when the daemon's home directory hasn't been\n * learned yet. Order matters — this mirrors the scope's classification table exactly:\n *\n * 1. empty/whitespace → external\n * 2. fragment-only (`#section`) → external (in-page anchor must not be intercepted)\n * 3. path+fragment (`README.md#usage`) → fragment stripped by resolver, remainder classifies\n * 4. `http:`/`https:` scheme → external\n * 5. any other `scheme:` prefix (incl. `file:`) → external\n * 6. `/…` → local, path used as-is\n * 7. `~`/`~/…` → local, `~` expanded (unknown home dir → external)\n * 8. `./…`, `../…`, bare relative → local, joined against `base` (no base → external)\n * 9. No extension gate (unlike images) — any resolved local path qualifies, including directories\n */\nexport function classifyFileLinkSrc(\n href: string,\n base: string | null,\n homeDir: string | null,\n): FileLinkClassification {\n const trimmed = href.trim();\n if (!trimmed) return { kind: \"external\" };\n\n // Fragment-only check: if the href contains only `#`, it's an in-page anchor\n if (trimmed.startsWith(\"#\")) return { kind: \"external\" };\n\n // Use the shared resolution step: strips fragments/query, resolves paths, normalizes + decodes\n const candidate = resolveHrefCandidate(href, base, homeDir);\n\n // If resolved successfully, it's a local file link; otherwise external\n if (candidate) {\n return { kind: \"local\", path: candidate };\n }\n return { kind: \"external\" };\n}\n","/**\n * `[label](href)` renderer for finalized assistant markdown (features/file-link-rendering.md §\n * Render pipeline, § Click-to-open pane targeting, § Drag-to-split). `classifyFileLinkSrc`'s\n * `external` result renders an ordinary, unmodified anchor — never intercepted, so in-page anchors\n * and real links keep working exactly as the browser's default. A `local` result renders an\n * actionable, draggable anchor: click prevents navigation and dispatches through the same\n * `openFileTab` primitive the Files tree and its context menu already use, targeted at the owning\n * chat tab's pane — the converged click-to-open dispatch `InlineImage` also uses\n * (features/inline-image-rendering.md § Click-to-open, amended); drag carries the identical\n * `EXTERNAL_DRAG_MIME.path` payload a Files-tree row drag carries, so dropping it onto a pane's\n * edge splits and opens it there, with zero drop-side changes needed.\n */\n\nimport type { ReactNode } from \"react\";\nimport { classifyFileLinkSrc } from \"./file-link-src.js\";\nimport { resolveFileOpenTarget } from \"./file-open-target.js\";\nimport { useHomeDir } from \"@pi-studio-ui/hooks/use-home-dir.js\";\nimport { openFileTab } from \"@pi-studio-ui/features/files/open-file-tab.js\";\nimport { pathDragStartHandler } from \"@pi-studio-ui/features/workspace/external-drag.js\";\n\nexport interface FileLinkProps {\n href?: string;\n children?: ReactNode;\n assetBase: string | null;\n owningPaneId: string | null;\n workspaceCwd: string | null;\n}\n\nexport function FileLink({ href, children, assetBase, owningPaneId, workspaceCwd }: FileLinkProps) {\n const homeDir = useHomeDir();\n const classification = classifyFileLinkSrc(href ?? \"\", assetBase, homeDir);\n\n if (classification.kind === \"external\") {\n return <a href={href}>{children}</a>;\n }\n\n const path = classification.path;\n const target = resolveFileOpenTarget(assetBase, owningPaneId, workspaceCwd);\n return (\n <a\n href={href}\n draggable\n onDragStart={pathDragStartHandler(path)}\n onClick={(event) => {\n event.preventDefault();\n openFileTab(path, target.workspaceCwd, target.targetPaneId);\n }}\n >\n {children}\n </a>\n );\n}\n","/**\n * react-markdown + remark-gfm rendering, replacing the POC's CDN `marked` + `innerHTML`\n * (POC_TO_APP_PLAN_UI.md §4.8). Sanitized by default — react-markdown never injects raw HTML.\n * Fenced code blocks highlight asynchronously via Shiki (`highlight.ts`).\n */\n\nimport { memo, useEffect, useState, type ComponentPropsWithoutRef } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { highlightToHtml } from \"./highlight.js\";\nimport styles from \"./markdown.module.css\";\nimport { InlineImage } from \"./InlineImage.js\";\nimport { FileLink } from \"./FileLink.js\";\n\ninterface CodeBlockProps {\n language: string;\n code: string;\n}\n\nfunction CodeBlock({ language, code }: CodeBlockProps) {\n const [html, setHtml] = useState<string | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n void highlightToHtml(code, language).then((result) => {\n if (!cancelled) setHtml(result);\n });\n return () => {\n cancelled = true;\n };\n }, [code, language]);\n\n if (html) {\n // Shiki's output is generated HTML from a trusted local highlighter (no user HTML is ever\n // injected) — the only risk is `code`, which Shiki escapes internally before tokenizing.\n return <div className={styles.prose} dangerouslySetInnerHTML={{ __html: html }} />;\n }\n return (\n <pre>\n <code>{code}</code>\n </pre>\n );\n}\n\ntype MarkdownCodeProps = ComponentPropsWithoutRef<\"code\"> & { className?: string };\n\nfunction CodeRenderer({ className, children, ...rest }: MarkdownCodeProps) {\n const match = /language-(\\w+)/.exec(className ?? \"\");\n const isBlock = Boolean(match);\n const code = String(children).replace(/\\n$/, \"\");\n if (isBlock && match) {\n return <CodeBlock language={match[1] ?? \"plaintext\"} code={code} />;\n }\n return (\n <code className={styles.inlineCode} {...rest}>\n {children}\n </code>\n );\n}\n\nexport interface MarkdownProps {\n text: string;\n assetBase?: string | null;\n owningPaneId?: string | null;\n workspaceCwd?: string | null;\n}\n\n/** Renders assistant/tool markdown text. Memoized — text is immutable once a turn finalizes. */\nexport const Markdown = memo(function Markdown({\n text,\n assetBase = null,\n owningPaneId = null,\n workspaceCwd = null,\n}: MarkdownProps) {\n return (\n <div className={styles.prose}>\n <ReactMarkdown\n remarkPlugins={[remarkGfm]}\n components={{\n code: CodeRenderer,\n img: (props) => (\n <InlineImage\n {...props}\n assetBase={assetBase}\n owningPaneId={owningPaneId}\n workspaceCwd={workspaceCwd}\n />\n ),\n a: (props) => (\n <FileLink\n {...props}\n assetBase={assetBase}\n owningPaneId={owningPaneId}\n workspaceCwd={workspaceCwd}\n />\n ),\n }}\n >\n {text}\n </ReactMarkdown>\n </div>\n );\n});\n"],"names":["THEME","highlighterPromise","loadedLangs","getHighlighter","createHighlighter","ensureLang","highlighter","lang","highlightToHtml","code","resolved","SCHEME_RE","percentDecode","str","resolveHrefCandidate","href","base","homeDir","trimmed","stripped","candidate","normalizeCwd","resolveWorkspacePath","normalized","collapseDotSegments","REMOTE_SCHEMES","classifyImageSrc","src","schemeMatch","detectViewerKind","selectInlineImageView","classification","imageState","alt","fallbackText","resolveFileOpenTarget","assetBase","owningPaneId","workspaceCwd","loadInlineImage","path","download","onStateChange","cancelled","acquireInlineImage","entry","error","releaseInlineImage","useInlineImage","client","useConnectionStore","s","daemon","state","setState","useState","useEffect","p","transferFor","InlineImage","memo","useHomeDir","useMemo","view","jsx","styles","Spinner","target","handleClick","openFileTab","pathDragStartHandler","classifyFileLinkSrc","FileLink","children","event","CodeBlock","language","html","setHtml","result","CodeRenderer","className","rest","match","isBlock","Markdown","text","ReactMarkdown","remarkGfm","props"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"markdown-lrvXpy4g.js","sources":["../../../src/timeline/highlight.ts","../../../src/timeline/href-resolution.ts","../../../src/timeline/image-src.ts","../../../src/timeline/inline-image-view.ts","../../../src/timeline/file-open-target.ts","../../../src/hooks/use-inline-image.ts","../../../src/timeline/InlineImage.tsx","../../../src/timeline/file-link-src.ts","../../../src/timeline/FileLink.tsx","../../../src/timeline/markdown.tsx"],"sourcesContent":["/**\n * Shiki wrapper + lang-from-path map. Replaces the POC's CDN `highlight.js`\n * (POC_TO_APP_PLAN_UI.md §2, §4.8). Grammars/themes are loaded lazily and cached — the\n * highlighter is not paid for until a code block/file view actually renders.\n */\n\nimport { createHighlighter, type Highlighter } from \"shiki\";\n\n/** POC `getLangFromPath` extension → language map, ported verbatim. */\nconst EXT_TO_LANG: Record<string, string> = {\n js: \"javascript\",\n ts: \"typescript\",\n jsx: \"javascript\",\n tsx: \"typescript\",\n py: \"python\",\n rb: \"ruby\",\n rs: \"rust\",\n go: \"go\",\n java: \"java\",\n c: \"c\",\n cpp: \"cpp\",\n h: \"c\",\n hpp: \"cpp\",\n cs: \"csharp\",\n html: \"html\",\n css: \"css\",\n scss: \"scss\",\n json: \"json\",\n yaml: \"yaml\",\n yml: \"yaml\",\n toml: \"toml\",\n xml: \"xml\",\n md: \"markdown\",\n sh: \"bash\",\n bash: \"bash\",\n sql: \"sql\",\n swift: \"swift\",\n kt: \"kotlin\",\n dart: \"dart\",\n vue: \"xml\",\n svelte: \"xml\",\n};\n\nexport function langFromPath(path: string): string {\n const ext = path.split(\".\").pop()?.toLowerCase() ?? \"\";\n return EXT_TO_LANG[ext] ?? \"plaintext\";\n}\n\nconst THEME = \"github-dark\";\n\nlet highlighterPromise: Promise<Highlighter> | null = null;\nconst loadedLangs = new Set<string>([\"plaintext\"]);\n\nfunction getHighlighter(): Promise<Highlighter> {\n if (!highlighterPromise) {\n highlighterPromise = createHighlighter({ themes: [THEME], langs: [\"plaintext\"] });\n }\n return highlighterPromise;\n}\n\nasync function ensureLang(highlighter: Highlighter, lang: string): Promise<string> {\n if (loadedLangs.has(lang)) return lang;\n try {\n await highlighter.loadLanguage(lang as never);\n loadedLangs.add(lang);\n return lang;\n } catch {\n return \"plaintext\"; // unknown grammar to Shiki — fall back rather than throw\n }\n}\n\n/** Highlight `code` for `lang` (or a path via `langFromPath`) to sanitized HTML. */\nexport async function highlightToHtml(code: string, lang: string): Promise<string> {\n const highlighter = await getHighlighter();\n const resolved = await ensureLang(highlighter, lang);\n return highlighter.codeToHtml(code, { lang: resolved, theme: THEME });\n}\n","/**\n * Shared href/src candidate resolution — used by both classifyImageSrc and classifyFileLinkSrc.\n *\n * Handles the common steps: strip fragments/query, detect schemes, expand ~/relative paths,\n * normalize, and percent-decode. Returns a resolved local candidate (absolute, normalized,\n * percent-decoded) or null if external/unresolvable.\n */\n\nimport { normalizeCwd } from \"@pi-studio-ui/features/sessions/workspace-grouping.js\";\nimport { collapseDotSegments, resolveWorkspacePath } from \"@pi-studio-ui/lib/paths.js\";\n\n/** Matches a leading `scheme:` (e.g. `http:`, `file:`, `mailto:`) — not a Windows drive letter\n * (`C:\\...`) or a bare `:` with no scheme name. */\nconst SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/;\n\n/**\n * Try to percent-decode a string using decodeURIComponent. On malformed sequences (e.g. `%E0%`),\n * return the original string unchanged rather than throwing.\n */\nfunction percentDecode(str: string): string {\n try {\n return decodeURIComponent(str);\n } catch {\n // Malformed percent sequence; return unchanged\n return str;\n }\n}\n\n/**\n * Shared resolution step for markdown hrefs/srcs. Strips fragments/query, detects schemes,\n * expands tilde and relatives, normalizes and percent-decodes the result.\n *\n * Returns a resolved local candidate (absolute path, normalized, percent-decoded) or null\n * if the input is external (has a scheme) or unresolvable (no base for a relative path, etc).\n *\n * @param href The raw markdown href/src\n * @param base The asset base (absolute, tilde-normalized) or null\n * @param homeDir The home directory or null\n * @returns The resolved candidate or null\n */\nexport function resolveHrefCandidate(\n href: string,\n base: string | null,\n homeDir: string | null,\n): string | null {\n const trimmed = href.trim();\n if (!trimmed) return null;\n\n // Strip `#fragment` and `?query` before classifying\n const stripped = trimmed.split(/[?#]/, 1)[0]!;\n if (!stripped) return null;\n\n // Detect explicit schemes (http:, file:, etc). If found, it's external — return null.\n const schemeMatch = SCHEME_RE.exec(stripped);\n if (schemeMatch) {\n return null; // Has a scheme; external\n }\n\n // Resolve the candidate: absolute, tilde-prefixed, or relative\n let candidate: string | null;\n if (stripped.startsWith(\"/\")) {\n candidate = stripped;\n } else if (stripped === \"~\" || stripped.startsWith(\"~/\")) {\n candidate = homeDir ? normalizeCwd(stripped, homeDir) : null;\n } else {\n // Relative path\n candidate = resolveWorkspacePath(stripped, base ?? \"\");\n }\n\n if (!candidate) return null;\n\n // Normalize (collapse ./ and ../ segments) and percent-decode\n const normalized = collapseDotSegments(candidate);\n return percentDecode(normalized);\n}\n","/**\n * Markdown image source classification (features/inline-image-rendering.md § Public Contract →\n * Markdown image source classification, § Asset base). The single gate every `` in a\n * rendered chat message passes through before any network request is even considered — deciding\n * remote (render directly), local (fetch via the daemon's file-transfer path, task-003), or\n * unresolvable (render as plain fallback text, never attempt a request).\n *\n * Pure: no store/hook/network imports, so this file needs no React or client mocks to test.\n */\n\nimport { detectViewerKind } from \"@pi-studio-ui/features/files/viewer-registry.js\";\nimport { resolveHrefCandidate } from \"./href-resolution.js\";\n\nexport type ImageSrcClassification =\n | { kind: \"remote\" }\n | { kind: \"local\"; path: string }\n | { kind: \"unresolvable\" };\n\nconst REMOTE_SCHEMES: Record<string, true> = {\n \"http:\": true,\n \"https:\": true,\n \"data:\": true,\n \"blob:\": true,\n};\n\n/** Matches a leading `scheme:` (e.g. `http:`, `file:`, `mailto:`) — not a Windows drive letter\n * (`C:\\...`) or a bare `:` with no scheme name. */\nconst SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/;\n\n/**\n * Classifies a markdown image `src`. `base` is the asset base (absolute, tilde-normalized) or\n * `null` when unknown; `homeDir` may be `null` when the daemon's home directory hasn't been\n * learned yet. Order matters — this mirrors the scope's classification table exactly:\n *\n * 1. empty/whitespace → unresolvable\n * 2. `http:`/`https:`/`data:`/`blob:` → remote\n * 3. any other `scheme:` prefix (including `file:`) → unresolvable\n * 4. `/…` → candidate local path as-is\n * 5. `~`/`~/…` → expand against `homeDir` (unknown home dir → unresolvable)\n * 6. `./…`, `../…`, bare relative → resolve against `base` (no base → unresolvable)\n * 7. final gate: the candidate must be an image per `detectViewerKind`, else unresolvable\n */\nexport function classifyImageSrc(\n src: string,\n base: string | null,\n homeDir: string | null,\n): ImageSrcClassification {\n const trimmed = src.trim();\n if (!trimmed) return { kind: \"unresolvable\" };\n\n // Markdown may carry a title/version via `#fragment`/`?query`; strip both before classifying so\n // a trailing `?` never defeats the extension gate below.\n const stripped = trimmed.split(/[?#]/, 1)[0]!;\n if (!stripped) return { kind: \"unresolvable\" };\n\n const schemeMatch = SCHEME_RE.exec(stripped);\n if (schemeMatch) {\n const scheme = `${schemeMatch[1]!.toLowerCase()}:`;\n return scheme in REMOTE_SCHEMES ? { kind: \"remote\" } : { kind: \"unresolvable\" };\n }\n\n // Use the shared resolution step: resolves path, applies normalization + percent-decoding\n const candidate = resolveHrefCandidate(src, base, homeDir);\n if (!candidate) return { kind: \"unresolvable\" };\n\n // Image-specific gate: must be an image extension\n if (detectViewerKind(candidate) !== \"image\") return { kind: \"unresolvable\" };\n return { kind: \"local\", path: candidate };\n}\n","/**\n * Pure, testable logic for rendering inline images — extracted from the component\n * so tests can verify branch logic without JSX rendering (mirror of molecule-reload.ts,\n * text-viewer-state.ts; see AGENTS.md testing conventions).\n *\n * Returns a discriminated union describing what to render: the component switches\n * on it to decide remote passthrough, skeleton, ready image, or text fallback.\n */\n\nimport type { ImageSrcClassification } from \"./image-src.js\";\nimport type { InlineImageState } from \"@pi-studio-ui/hooks/use-inline-image.js\";\n\nexport type InlineImageView =\n | { kind: \"remote\"; src: string; alt?: string }\n | { kind: \"unresolvable\"; fallbackText: string }\n | { kind: \"loading\" }\n | { kind: \"ready\"; objectUrl: string; alt?: string; path: string }\n | { kind: \"error\"; fallbackText: string };\n\n/**\n * Decides what to render for an inline image given its classification and loading state.\n * Pure: no hooks, no store access — all state passed in as arguments.\n */\nexport function selectInlineImageView(\n classification: ImageSrcClassification,\n imageState: InlineImageState,\n src: string | undefined,\n alt: string | undefined,\n): InlineImageView {\n if (classification.kind === \"remote\") {\n return { kind: \"remote\", src: src || \"\", alt };\n }\n\n if (classification.kind === \"unresolvable\") {\n const fallbackText = alt || src || \"(image)\";\n return { kind: \"unresolvable\", fallbackText };\n }\n\n // classification.kind === \"local\"\n if (imageState.status === \"loading\" || imageState.status === \"idle\") {\n return { kind: \"loading\" };\n }\n\n if (imageState.status === \"ready\") {\n return { kind: \"ready\", objectUrl: imageState.objectUrl, alt, path: classification.path };\n }\n\n // imageState.status === \"error\"\n const fallbackText = alt || classification.path || \"(image)\";\n return { kind: \"error\", fallbackText };\n}\n","/**\n * Resolves the `openFileTab` workspace-cwd/target-pane arguments shared by `FileLink` and\n * `InlineImage`'s converged click-to-open dispatch (features/file-link-rendering.md §\n * Click-to-open pane targeting, features/inline-image-rendering.md § Click-to-open, amended).\n *\n * The owning chat tab's real `workspaceCwd` always wins — `assetBase || \"~\"` is only a fallback\n * for markdown surfaces rendered outside any tab, where no owning tab exists to name; the chat\n * render path always threads a real `workspaceCwd` (task-002) and never hits the fallback.\n * `owningPaneId`'s `null` (no owning pane yet known, e.g. a tab not yet placed by restore)\n * converts to `undefined`, matching `openFileTab`'s `targetPaneId: string | undefined` contract —\n * `undefined` falls back to whichever pane is currently focused.\n *\n * Pure: no store/hook/network imports, so this file needs no React or client mocks to test.\n */\nexport function resolveFileOpenTarget(\n assetBase: string | null,\n owningPaneId: string | null,\n workspaceCwd: string | null,\n): { workspaceCwd: string; targetPaneId: string | undefined } {\n return {\n workspaceCwd: workspaceCwd ?? (assetBase || \"~\"),\n targetPaneId: owningPaneId ?? undefined,\n };\n}\n","/**\n * `use-inline-image` — fetches (via the shared ref-counted `inline-image-cache`) and hands back\n * an object URL for an inline chat image (features/inline-image-rendering.md § Inline image fetch\n * + cache). Consumed by the markdown `img` node override (task-004) once a `classifyImageSrc`\n * result is `local`.\n *\n * Deliberately NOT a TanStack Query hook, and therefore no `rpcKeys` entry: the whole point of\n * `inline-image-cache.ts` is a retention policy (revoke only on LRU eviction, never on unmount)\n * that Query's cache cannot express. Do not \"fix\" this into a `useQuery` — see that module's\n * header for why.\n *\n * `loadInlineImage` is the actual effect body, extracted for direct unit testing without\n * rendering — this package has no jsdom/React-Testing-Library environment (`AGENTS.md`'s testing\n * convention; mirrors `use-file-watch.ts`'s `watchFile` extraction).\n */\n\nimport { useEffect, useState } from \"react\";\nimport { useConnectionStore } from \"@pi-studio-ui/lib/connection/connection-store.js\";\nimport { acquireInlineImage, releaseInlineImage } from \"@pi-studio-ui/lib/inline-image-cache.js\";\nimport { transferFor } from \"./file-transfer-instance.js\";\n\nexport type InlineImageState =\n | { status: \"idle\" } // no client yet, or path is null\n | { status: \"loading\" }\n | { status: \"ready\"; objectUrl: string }\n | { status: \"error\"; message: string };\n\n/**\n * Runs one effect cycle: `download === null` (no live connection, or no path) is the idle case —\n * nothing to acquire, nothing to clean up. Otherwise acquires `path`, reports the loading →\n * ready/error transition via `onStateChange`, and returns a cleanup that releases the path and\n * ignores a resolution that lands after it (the `cancelled` flag pattern used across the existing\n * hooks, e.g. `use-checkout-status.ts`).\n */\nexport function loadInlineImage(\n path: string | null,\n download: ((path: string) => Promise<{ bytes: Uint8Array; mimeType?: string }>) | null,\n onStateChange: (state: InlineImageState) => void,\n): (() => void) | void {\n if (!path || !download) {\n onStateChange({ status: \"idle\" });\n return;\n }\n\n let cancelled = false;\n onStateChange({ status: \"loading\" });\n\n acquireInlineImage(path, download)\n .then((entry) => {\n if (!cancelled) onStateChange({ status: \"ready\", objectUrl: entry.objectUrl });\n })\n .catch((error: unknown) => {\n if (!cancelled) {\n onStateChange({\n status: \"error\",\n message: error instanceof Error ? error.message : String(error),\n });\n }\n });\n\n return () => {\n cancelled = true;\n releaseInlineImage(path);\n };\n}\n\nexport function useInlineImage(path: string | null): InlineImageState {\n const client = useConnectionStore((s) => s.client);\n const daemon = useConnectionStore((s) => s.daemon);\n const [state, setState] = useState<InlineImageState>({ status: \"idle\" });\n\n useEffect(() => {\n const download = client && daemon ? (p: string) => transferFor(daemon).download(p) : null;\n return loadInlineImage(path, download, setState);\n }, [client, daemon, path]);\n\n return state;\n}\n","/**\n * `` renderer for finalized assistant markdown (features/inline-image-rendering.md §\n * Behavior & Algorithms → Render pipeline). Handles remote passthrough, local file fetch via\n * useInlineImage (task-003), classification (task-002), and click-to-open file tab.\n *\n * Props are optional (react-markdown may omit them) and must be handled defensively.\n */\n\nimport { memo, useMemo } from \"react\";\nimport { classifyImageSrc } from \"./image-src.js\";\nimport { selectInlineImageView } from \"./inline-image-view.js\";\nimport { resolveFileOpenTarget } from \"./file-open-target.js\";\nimport { useInlineImage } from \"@pi-studio-ui/hooks/use-inline-image.js\";\nimport { useHomeDir } from \"@pi-studio-ui/hooks/use-home-dir.js\";\nimport { openFileTab } from \"@pi-studio-ui/features/files/open-file-tab.js\";\nimport { pathDragStartHandler } from \"@pi-studio-ui/features/workspace/external-drag.js\";\nimport { Spinner } from \"@pi-studio-ui/components/primitives/Spinner.js\";\nimport styles from \"./markdown.module.css\";\n\nexport interface InlineImageProps {\n src?: string;\n alt?: string;\n assetBase: string | null;\n owningPaneId?: string | null;\n workspaceCwd?: string | null;\n}\n\nexport const InlineImage = memo(function InlineImage({\n src,\n alt,\n assetBase,\n owningPaneId,\n workspaceCwd,\n}: InlineImageProps) {\n const homeDir = useHomeDir();\n\n // Classify the source once at render time.\n const classification = useMemo(\n () => classifyImageSrc(src || \"\", assetBase, homeDir),\n [src, assetBase, homeDir],\n );\n\n // For local images, fetch and track state.\n const imageState = useInlineImage(classification.kind === \"local\" ? classification.path : null);\n\n // Decide what to render.\n const view = selectInlineImageView(classification, imageState, src, alt);\n\n // Remote images: pass through unchanged (what react-markdown's default would do).\n if (view.kind === \"remote\") {\n return <img src={view.src} alt={view.alt} className={styles.inlineImage} />;\n }\n\n // Unresolvable or error: render text fallback in inline monospace.\n if (view.kind === \"unresolvable\" || view.kind === \"error\") {\n return <code className={styles.inlineImageFallback}>{view.fallbackText}</code>;\n }\n\n // Loading or idle: skeleton.\n if (view.kind === \"loading\") {\n return (\n <div className={styles.inlineImageSkeleton}>\n <Spinner size=\"sm\" />\n </div>\n );\n }\n\n // Ready: render the fetched image with click-to-open handler, converged onto the same\n // `openFileTab` dispatch the Files tree and `FileLink` use — puts the pane-targeting fix\n // (owning tab's real `workspaceCwd`, `owningPaneId`) in exactly one place for both features.\n const target = resolveFileOpenTarget(assetBase, owningPaneId ?? null, workspaceCwd ?? null);\n const handleClick = () => {\n openFileTab(view.path, target.workspaceCwd, target.targetPaneId);\n };\n\n return (\n <img\n src={view.objectUrl}\n alt={view.alt}\n className={styles.inlineImage}\n title={view.path}\n draggable\n onDragStart={pathDragStartHandler(view.path)}\n onClick={handleClick}\n style={{ cursor: \"pointer\" }}\n />\n );\n});\n","/**\n * Markdown file link source classification (features/file-link-rendering.md § Public Contract →\n * Markdown link source classification). Decides whether a markdown link `[label](href)` should\n * open a local file (intercepted by the client) or navigate as a regular link.\n *\n * Two-way classification simpler than the image classifier's three-way one:\n * - `local`: resolves to a local filesystem path → intercept, open as a file\n * - `external`: anything else → pass through as a regular navigating anchor\n *\n * Pure: no store/hook/network imports, so this file needs no React or client mocks to test.\n */\n\nimport { resolveHrefCandidate } from \"./href-resolution.js\";\n\nexport type FileLinkClassification = { kind: \"local\"; path: string } | { kind: \"external\" };\n\n/**\n * Classifies a markdown link `href`. `base` is the asset base (absolute, tilde-normalized) or\n * `null` when unknown; `homeDir` may be `null` when the daemon's home directory hasn't been\n * learned yet. Order matters — this mirrors the scope's classification table exactly:\n *\n * 1. empty/whitespace → external\n * 2. fragment-only (`#section`) → external (in-page anchor must not be intercepted)\n * 3. path+fragment (`README.md#usage`) → fragment stripped by resolver, remainder classifies\n * 4. `http:`/`https:` scheme → external\n * 5. any other `scheme:` prefix (incl. `file:`) → external\n * 6. `/…` → local, path used as-is\n * 7. `~`/`~/…` → local, `~` expanded (unknown home dir → external)\n * 8. `./…`, `../…`, bare relative → local, joined against `base` (no base → external)\n * 9. No extension gate (unlike images) — any resolved local path qualifies, including directories\n */\nexport function classifyFileLinkSrc(\n href: string,\n base: string | null,\n homeDir: string | null,\n): FileLinkClassification {\n const trimmed = href.trim();\n if (!trimmed) return { kind: \"external\" };\n\n // Fragment-only check: if the href contains only `#`, it's an in-page anchor\n if (trimmed.startsWith(\"#\")) return { kind: \"external\" };\n\n // Use the shared resolution step: strips fragments/query, resolves paths, normalizes + decodes\n const candidate = resolveHrefCandidate(href, base, homeDir);\n\n // If resolved successfully, it's a local file link; otherwise external\n if (candidate) {\n return { kind: \"local\", path: candidate };\n }\n return { kind: \"external\" };\n}\n","/**\n * `[label](href)` renderer for finalized assistant markdown (features/file-link-rendering.md §\n * Render pipeline, § Click-to-open pane targeting, § Drag-to-split). `classifyFileLinkSrc`'s\n * `external` result renders an ordinary, unmodified anchor — never intercepted, so in-page anchors\n * and real links keep working exactly as the browser's default. A `local` result renders an\n * actionable, draggable anchor: click prevents navigation and dispatches through the same\n * `openFileTab` primitive the Files tree and its context menu already use, targeted at the owning\n * chat tab's pane — the converged click-to-open dispatch `InlineImage` also uses\n * (features/inline-image-rendering.md § Click-to-open, amended); drag carries the identical\n * `EXTERNAL_DRAG_MIME.path` payload a Files-tree row drag carries, so dropping it onto a pane's\n * edge splits and opens it there, with zero drop-side changes needed.\n */\n\nimport type { ReactNode } from \"react\";\nimport { classifyFileLinkSrc } from \"./file-link-src.js\";\nimport { resolveFileOpenTarget } from \"./file-open-target.js\";\nimport { useHomeDir } from \"@pi-studio-ui/hooks/use-home-dir.js\";\nimport { openFileTab } from \"@pi-studio-ui/features/files/open-file-tab.js\";\nimport { pathDragStartHandler } from \"@pi-studio-ui/features/workspace/external-drag.js\";\n\nexport interface FileLinkProps {\n href?: string;\n children?: ReactNode;\n assetBase: string | null;\n owningPaneId: string | null;\n workspaceCwd: string | null;\n}\n\nexport function FileLink({ href, children, assetBase, owningPaneId, workspaceCwd }: FileLinkProps) {\n const homeDir = useHomeDir();\n const classification = classifyFileLinkSrc(href ?? \"\", assetBase, homeDir);\n\n if (classification.kind === \"external\") {\n return <a href={href}>{children}</a>;\n }\n\n const path = classification.path;\n const target = resolveFileOpenTarget(assetBase, owningPaneId, workspaceCwd);\n return (\n <a\n href={href}\n draggable\n onDragStart={pathDragStartHandler(path)}\n onClick={(event) => {\n event.preventDefault();\n openFileTab(path, target.workspaceCwd, target.targetPaneId);\n }}\n >\n {children}\n </a>\n );\n}\n","/**\n * react-markdown + remark-gfm rendering, replacing the POC's CDN `marked` + `innerHTML`\n * (POC_TO_APP_PLAN_UI.md §4.8). Sanitized by default — react-markdown never injects raw HTML.\n * Fenced code blocks highlight asynchronously via Shiki (`highlight.ts`).\n */\n\nimport { memo, useEffect, useState, type ComponentPropsWithoutRef } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { highlightToHtml } from \"./highlight.js\";\nimport styles from \"./markdown.module.css\";\nimport { InlineImage } from \"./InlineImage.js\";\nimport { FileLink } from \"./FileLink.js\";\n\ninterface CodeBlockProps {\n language: string;\n code: string;\n}\n\nfunction CodeBlock({ language, code }: CodeBlockProps) {\n const [html, setHtml] = useState<string | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n void highlightToHtml(code, language).then((result) => {\n if (!cancelled) setHtml(result);\n });\n return () => {\n cancelled = true;\n };\n }, [code, language]);\n\n if (html) {\n // Shiki's output is generated HTML from a trusted local highlighter (no user HTML is ever\n // injected) — the only risk is `code`, which Shiki escapes internally before tokenizing.\n return <div className={styles.prose} dangerouslySetInnerHTML={{ __html: html }} />;\n }\n return (\n <pre>\n <code>{code}</code>\n </pre>\n );\n}\n\ntype MarkdownCodeProps = ComponentPropsWithoutRef<\"code\"> & { className?: string };\n\nfunction CodeRenderer({ className, children, ...rest }: MarkdownCodeProps) {\n const match = /language-(\\w+)/.exec(className ?? \"\");\n const isBlock = Boolean(match);\n const code = String(children).replace(/\\n$/, \"\");\n if (isBlock && match) {\n return <CodeBlock language={match[1] ?? \"plaintext\"} code={code} />;\n }\n return (\n <code className={styles.inlineCode} {...rest}>\n {children}\n </code>\n );\n}\n\nexport interface MarkdownProps {\n text: string;\n assetBase?: string | null;\n owningPaneId?: string | null;\n workspaceCwd?: string | null;\n}\n\n/** Renders assistant/tool markdown text. Memoized — text is immutable once a turn finalizes. */\nexport const Markdown = memo(function Markdown({\n text,\n assetBase = null,\n owningPaneId = null,\n workspaceCwd = null,\n}: MarkdownProps) {\n return (\n <div className={styles.prose}>\n <ReactMarkdown\n remarkPlugins={[remarkGfm]}\n components={{\n code: CodeRenderer,\n img: (props) => (\n <InlineImage\n {...props}\n assetBase={assetBase}\n owningPaneId={owningPaneId}\n workspaceCwd={workspaceCwd}\n />\n ),\n a: (props) => (\n <FileLink\n {...props}\n assetBase={assetBase}\n owningPaneId={owningPaneId}\n workspaceCwd={workspaceCwd}\n />\n ),\n }}\n >\n {text}\n </ReactMarkdown>\n </div>\n );\n});\n"],"names":["THEME","highlighterPromise","loadedLangs","getHighlighter","createHighlighter","ensureLang","highlighter","lang","highlightToHtml","code","resolved","SCHEME_RE","percentDecode","str","resolveHrefCandidate","href","base","homeDir","trimmed","stripped","candidate","normalizeCwd","resolveWorkspacePath","normalized","collapseDotSegments","REMOTE_SCHEMES","classifyImageSrc","src","schemeMatch","detectViewerKind","selectInlineImageView","classification","imageState","alt","fallbackText","resolveFileOpenTarget","assetBase","owningPaneId","workspaceCwd","loadInlineImage","path","download","onStateChange","cancelled","acquireInlineImage","entry","error","releaseInlineImage","useInlineImage","client","useConnectionStore","s","daemon","state","setState","useState","useEffect","p","transferFor","InlineImage","memo","useHomeDir","useMemo","view","jsx","styles","Spinner","target","handleClick","openFileTab","pathDragStartHandler","classifyFileLinkSrc","FileLink","children","event","CodeBlock","language","html","setHtml","result","CodeRenderer","className","rest","match","isBlock","Markdown","text","ReactMarkdown","remarkGfm","props"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as a}from"./vendor-react-CWo7Do4s.js";import{u as f}from"./vendor-query-BGIh-vlp.js";import{a as s,o as m,r as y}from"./index-
|
|
2
|
-
//# sourceMappingURL=use-file-download-
|
|
1
|
+
import{r as a}from"./vendor-react-CWo7Do4s.js";import{u as f}from"./vendor-query-BGIh-vlp.js";import{a as s,o as m,r as y}from"./index-IJCh_DKC.js";function p(o,u=!0){var i;const l=s(e=>e.client),n=s(e=>e.daemon),c=f({queryKey:y.fileDownload(o),queryFn:async()=>{if(!l||!n)throw new Error("not connected");const e=await m(n).download(o),t=new Blob([Uint8Array.from(e.bytes)],{type:e.mimeType||"application/octet-stream"});return{objectUrl:URL.createObjectURL(t),mimeType:e.mimeType,fileName:e.fileName}},enabled:!!l&&!!n&&!!o&&u,staleTime:1/0,gcTime:0}),r=a.useRef(null);return a.useEffect(()=>{var t;const e=((t=c.data)==null?void 0:t.objectUrl)??null;e&&r.current&&r.current!==e&&URL.revokeObjectURL(r.current),r.current=e},[(i=c.data)==null?void 0:i.objectUrl]),a.useEffect(()=>()=>{r.current&&URL.revokeObjectURL(r.current)},[]),c}export{p as u};
|
|
2
|
+
//# sourceMappingURL=use-file-download-DLJldTqH.js.map
|
package/dist/web/assets/{use-file-download-Dv5HRkkB.js.map → use-file-download-DLJldTqH.js.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-file-download-
|
|
1
|
+
{"version":3,"file":"use-file-download-DLJldTqH.js","sources":["../../../src/hooks/use-file-download.ts"],"sourcesContent":["/**\n * `use-file-download` — TanStack Query wrapper over the binary file-transfer download path\n * (`file_download_token_request` → `file_download_request` → chunked `Begin`/`Chunk`/`End`\n * binary frames), for viewers that need raw bytes rather than the UTF-8 preview\n * `file_read_request` returns (images, video — POC_TO_APP_PLAN_UI.md §4.5 follow-up: modular\n * file preview). Resolves to an object URL, revoked once superseded by a new one or once this\n * consumer unmounts.\n */\n\nimport { useEffect, useRef } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { useConnectionStore } from \"@pi-studio-ui/lib/connection/connection-store.js\";\nimport { rpcKeys } from \"@pi-studio-ui/lib/connection/rpc-keys.js\";\nimport { transferFor } from \"./file-transfer-instance.js\";\n\nexport interface FileDownload {\n objectUrl: string;\n mimeType?: string;\n fileName?: string;\n}\n\nexport function useFileDownload(path: string, enabled = true) {\n const client = useConnectionStore((s) => s.client);\n const daemon = useConnectionStore((s) => s.daemon);\n\n const query = useQuery({\n queryKey: rpcKeys.fileDownload(path),\n queryFn: async (): Promise<FileDownload> => {\n if (!client || !daemon) throw new Error(\"not connected\");\n const file = await transferFor(daemon).download(path);\n // `Blob`'s DOM typings require an `ArrayBuffer`-backed view; the assembled `Uint8Array`'s\n // `ArrayBufferLike` backing (a decode-time implementation detail) doesn't satisfy that on\n // its own, so copy into a plain `ArrayBuffer`-backed `Uint8Array` first.\n const blob = new Blob([Uint8Array.from(file.bytes)], {\n type: file.mimeType || \"application/octet-stream\",\n });\n return {\n objectUrl: URL.createObjectURL(blob),\n mimeType: file.mimeType,\n fileName: file.fileName,\n };\n },\n enabled: Boolean(client) && Boolean(daemon) && Boolean(path) && enabled,\n // Object URLs are process-local, non-serializable browser resources — never persist them or\n // silently refetch behind this hook's back (that would leak the previous blob's URL).\n staleTime: Infinity,\n gcTime: 0,\n });\n\n // Revoke the previous object URL once a new one supersedes it, and the current one on unmount\n // — TanStack Query's cache has no notion that this value owns a browser resource to release.\n const urlRef = useRef<string | null>(null);\n useEffect(() => {\n const nextUrl = query.data?.objectUrl ?? null;\n if (nextUrl && urlRef.current && urlRef.current !== nextUrl) {\n URL.revokeObjectURL(urlRef.current);\n }\n urlRef.current = nextUrl;\n }, [query.data?.objectUrl]);\n\n useEffect(() => {\n return () => {\n if (urlRef.current) URL.revokeObjectURL(urlRef.current);\n };\n }, []);\n\n return query;\n}\n"],"names":["useFileDownload","path","enabled","client","useConnectionStore","s","daemon","query","useQuery","rpcKeys","file","transferFor","blob","urlRef","useRef","useEffect","nextUrl","_a"],"mappings":"oJAqBO,SAASA,EAAgBC,EAAcC,EAAU,GAAM,OAC5D,MAAMC,EAASC,EAAoBC,GAAMA,EAAE,MAAM,EAC3CC,EAASF,EAAoBC,GAAMA,EAAE,MAAM,EAE3CE,EAAQC,EAAS,CACrB,SAAUC,EAAQ,aAAaR,CAAI,EACnC,QAAS,SAAmC,CAC1C,GAAI,CAACE,GAAU,CAACG,EAAQ,MAAM,IAAI,MAAM,eAAe,EACvD,MAAMI,EAAO,MAAMC,EAAYL,CAAM,EAAE,SAASL,CAAI,EAI9CW,EAAO,IAAI,KAAK,CAAC,WAAW,KAAKF,EAAK,KAAK,CAAC,EAAG,CACnD,KAAMA,EAAK,UAAY,0BAAA,CACxB,EACD,MAAO,CACL,UAAW,IAAI,gBAAgBE,CAAI,EACnC,SAAUF,EAAK,SACf,SAAUA,EAAK,QAAA,CAEnB,EACA,QAAS,EAAQP,GAAW,EAAQG,GAAW,EAAQL,GAASC,EAGhE,UAAW,IACX,OAAQ,CAAA,CACT,EAIKW,EAASC,EAAAA,OAAsB,IAAI,EACzCC,OAAAA,EAAAA,UAAU,IAAM,OACd,MAAMC,IAAUC,EAAAV,EAAM,OAAN,YAAAU,EAAY,YAAa,KACrCD,GAAWH,EAAO,SAAWA,EAAO,UAAYG,GAClD,IAAI,gBAAgBH,EAAO,OAAO,EAEpCA,EAAO,QAAUG,CACnB,EAAG,EAACC,EAAAV,EAAM,OAAN,YAAAU,EAAY,SAAS,CAAC,EAE1BF,EAAAA,UAAU,IACD,IAAM,CACPF,EAAO,SAAS,IAAI,gBAAgBA,EAAO,OAAO,CACxD,EACC,CAAA,CAAE,EAEEN,CACT"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as a}from"./vendor-react-CWo7Do4s.js";import{a as w}from"./index-
|
|
2
|
-
//# sourceMappingURL=use-file-watch-
|
|
1
|
+
import{r as a}from"./vendor-react-CWo7Do4s.js";import{a as w}from"./index-IJCh_DKC.js";function b(e){return typeof e=="object"&&e!==null&&"type"in e&&e.type==="file_changed"&&"path"in e&&typeof e.path=="string"}const l=new WeakMap;function p(e,t,i){let s=l.get(e);s||(s=new Map,l.set(e,s));let o=s.get(t);if(!o){const r={count:0,resolvedPath:null,listeners:new Set,dispose:()=>{}},f=e.connection.onSessionMessage(c=>{const n=c;if(!b(n)||n.path!==t&&n.path!==r.resolvedPath)return;const d=Date.now();for(const h of r.listeners)h(d)});e.connection.request("file_watch_subscribe",{path:t}).then(c=>{const n=c;if((n==null?void 0:n.ok)===!1){console.warn(`watchFile: ${n.error??"subscribe failed"} for "${t}"`);return}typeof(n==null?void 0:n.path)=="string"&&(r.resolvedPath=n.path)}).catch(()=>{}),r.dispose=()=>{f(),e.connection.request("file_watch_unsubscribe",{path:t}).catch(()=>{})},o=r,s.set(t,o)}o.count++,o.listeners.add(i);let u=!1;return()=>{if(u)return;u=!0;const r=s.get(t);r&&(r.listeners.delete(i),r.count--,r.count===0&&(s.delete(t),r.dispose()))}}function v(e){const t=w(o=>o.client),[i,s]=a.useState(null);return a.useEffect(()=>{if(!(!t||!e))return p(t,e,s)},[t,e]),{changedAt:i}}export{v as u};
|
|
2
|
+
//# sourceMappingURL=use-file-watch-B-hlDpYI.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-file-watch-
|
|
1
|
+
{"version":3,"file":"use-file-watch-B-hlDpYI.js","sources":["../../../src/hooks/use-file-watch.ts"],"sourcesContent":["/**\n * `use-file-watch` — subscribes to live daemon `file_changed` pushes for a path (task-006's\n * `FileWatchService`), structurally mirroring `use-checkout-status.ts`: subscribe on mount/path\n * change via `file_watch_subscribe`, route matching pushes through `onSessionMessage`, and on\n * cleanup detach the message handler AND send `file_watch_unsubscribe` for the path being left —\n * `use-checkout-status.ts`'s own header comment calls out that leaving either half undone is the\n * leak this pattern exists to avoid.\n *\n * The actual subscribe/route/cleanup wiring lives in `watchFile`, a plain function with no React\n * dependency (mirrors `molecule-source.ts`/`molecule-reload.ts`'s \"extract the testable core\"\n * convention) — this repo has no DOM test environment set up, so it is the seam\n * `use-file-watch.test.ts` exercises directly with a fake client rather than mounting the hook.\n *\n * A `null` path (an empty \"+\"-menu molecule tab, or the absence of a client) subscribes nothing.\n *\n * `watchFile` ref-counts subscriptions per `(client, path)` in a module-level registry (mirrors\n * `file-transfer-instance.ts`'s `WeakMap<DaemonClient, …>` shared-instance convention): more than\n * one component can watch the same file (a file tab and a diff tab on the same path) and share\n * ONE daemon watch, rather than the second `file_watch_unsubscribe` killing it for both — the\n * daemon's `SessionSubscriptions.add` disposes any existing entry for the same resolved-path key.\n * The entry also remembers the RESOLVED path echoed back by `file_watch_subscribe_response.path`\n * (the daemon expands a leading `~` server-side before pushing `file_changed`), and matches a\n * push against either the requested spelling or that resolved path.\n */\n\nimport { useEffect, useState } from \"react\";\nimport type { PiStudioClient } from \"@av-pi-studio/client\";\nimport { useConnectionStore } from \"@pi-studio-ui/lib/connection/connection-store.js\";\n\ninterface FileChangedMessage {\n type: \"file_changed\";\n path: string;\n}\n\nfunction isFileChangedMessage(msg: unknown): msg is FileChangedMessage {\n return (\n typeof msg === \"object\" &&\n msg !== null &&\n \"type\" in msg &&\n msg.type === \"file_changed\" &&\n \"path\" in msg &&\n typeof msg.path === \"string\"\n );\n}\n\ninterface FileWatchSubscribeResponse {\n ok?: boolean;\n error?: string;\n path?: string;\n}\n\ninterface WatchEntry {\n count: number;\n /** Resolved path echoed by `file_watch_subscribe_response.path`; `null` until it arrives. */\n resolvedPath: string | null;\n listeners: Set<(changedAt: number) => void>;\n dispose: () => void;\n}\n\nconst watchesByClient = new WeakMap<Pick<PiStudioClient, \"connection\">, Map<string, WatchEntry>>();\n\n/** Subscribes `path` on `client.connection`, calls `onChanged` for every matching `file_changed`\n * push, and returns a cleanup function that detaches the handler AND sends\n * `file_watch_unsubscribe` for `path` once the LAST subscriber for that `(client, path)` pair\n * releases it. Framework-agnostic on purpose — see module header. */\nexport function watchFile(\n client: Pick<PiStudioClient, \"connection\">,\n path: string,\n onChanged: (changedAt: number) => void,\n): () => void {\n let entries = watchesByClient.get(client);\n if (!entries) {\n entries = new Map<string, WatchEntry>();\n watchesByClient.set(client, entries);\n }\n\n let entry = entries.get(path);\n if (!entry) {\n const created: WatchEntry = {\n count: 0,\n resolvedPath: null,\n listeners: new Set(),\n dispose: () => {},\n };\n\n const unsubscribeMessages = client.connection.onSessionMessage((msg) => {\n const raw: unknown = msg;\n if (!isFileChangedMessage(raw)) return;\n if (raw.path !== path && raw.path !== created.resolvedPath) return;\n const changedAt = Date.now();\n for (const listener of created.listeners) listener(changedAt);\n });\n\n void client.connection\n .request(\"file_watch_subscribe\", { path })\n .then((res) => {\n const response = res as FileWatchSubscribeResponse;\n if (response?.ok === false) {\n console.warn(`watchFile: ${response.error ?? \"subscribe failed\"} for \"${path}\"`);\n return;\n }\n if (typeof response?.path === \"string\") {\n created.resolvedPath = response.path;\n }\n })\n .catch(() => {});\n\n created.dispose = () => {\n unsubscribeMessages();\n void client.connection.request(\"file_watch_unsubscribe\", { path }).catch(() => {});\n };\n\n entry = created;\n entries.set(path, entry);\n }\n\n entry.count++;\n entry.listeners.add(onChanged);\n\n let released = false;\n return () => {\n if (released) return; // Idempotent: a second call must never double-release.\n released = true;\n const current = entries!.get(path);\n if (!current) return;\n current.listeners.delete(onChanged);\n current.count--;\n if (current.count === 0) {\n entries!.delete(path);\n current.dispose();\n }\n };\n}\n\nexport interface UseFileWatchResult {\n /** Monotonic timestamp of the last `file_changed` push for this path; `null` if none yet. */\n changedAt: number | null;\n}\n\nexport function useFileWatch(path: string | null): UseFileWatchResult {\n const client = useConnectionStore((s) => s.client);\n const [changedAt, setChangedAt] = useState<number | null>(null);\n\n useEffect(() => {\n if (!client || !path) return;\n return watchFile(client, path, setChangedAt);\n }, [client, path]);\n\n return { changedAt };\n}\n"],"names":["isFileChangedMessage","msg","watchesByClient","watchFile","client","path","onChanged","entries","entry","created","unsubscribeMessages","raw","changedAt","listener","res","response","released","current","useFileWatch","useConnectionStore","s","setChangedAt","useState","useEffect"],"mappings":"uFAkCA,SAASA,EAAqBC,EAAyC,CACrE,OACE,OAAOA,GAAQ,UACfA,IAAQ,MACR,SAAUA,GACVA,EAAI,OAAS,gBACb,SAAUA,GACV,OAAOA,EAAI,MAAS,QAExB,CAgBA,MAAMC,MAAsB,QAMrB,SAASC,EACdC,EACAC,EACAC,EACY,CACZ,IAAIC,EAAUL,EAAgB,IAAIE,CAAM,EACnCG,IACHA,MAAc,IACdL,EAAgB,IAAIE,EAAQG,CAAO,GAGrC,IAAIC,EAAQD,EAAQ,IAAIF,CAAI,EAC5B,GAAI,CAACG,EAAO,CACV,MAAMC,EAAsB,CAC1B,MAAO,EACP,aAAc,KACd,cAAe,IACf,QAAS,IAAM,CAAC,CAAA,EAGZC,EAAsBN,EAAO,WAAW,iBAAkBH,GAAQ,CACtE,MAAMU,EAAeV,EAErB,GADI,CAACD,EAAqBW,CAAG,GACzBA,EAAI,OAASN,GAAQM,EAAI,OAASF,EAAQ,aAAc,OAC5D,MAAMG,EAAY,KAAK,IAAA,EACvB,UAAWC,KAAYJ,EAAQ,UAAWI,EAASD,CAAS,CAC9D,CAAC,EAEIR,EAAO,WACT,QAAQ,uBAAwB,CAAE,KAAAC,EAAM,EACxC,KAAMS,GAAQ,CACb,MAAMC,EAAWD,EACjB,IAAIC,GAAA,YAAAA,EAAU,MAAO,GAAO,CAC1B,QAAQ,KAAK,cAAcA,EAAS,OAAS,kBAAkB,SAASV,CAAI,GAAG,EAC/E,MACF,CACI,OAAOU,GAAA,YAAAA,EAAU,OAAS,WAC5BN,EAAQ,aAAeM,EAAS,KAEpC,CAAC,EACA,MAAM,IAAM,CAAC,CAAC,EAEjBN,EAAQ,QAAU,IAAM,CACtBC,EAAA,EACKN,EAAO,WAAW,QAAQ,yBAA0B,CAAE,KAAAC,CAAA,CAAM,EAAE,MAAM,IAAM,CAAC,CAAC,CACnF,EAEAG,EAAQC,EACRF,EAAQ,IAAIF,EAAMG,CAAK,CACzB,CAEAA,EAAM,QACNA,EAAM,UAAU,IAAIF,CAAS,EAE7B,IAAIU,EAAW,GACf,MAAO,IAAM,CACX,GAAIA,EAAU,OACdA,EAAW,GACX,MAAMC,EAAUV,EAAS,IAAIF,CAAI,EAC5BY,IACLA,EAAQ,UAAU,OAAOX,CAAS,EAClCW,EAAQ,QACJA,EAAQ,QAAU,IACpBV,EAAS,OAAOF,CAAI,EACpBY,EAAQ,QAAA,GAEZ,CACF,CAOO,SAASC,EAAab,EAAyC,CACpE,MAAMD,EAASe,EAAoBC,GAAMA,EAAE,MAAM,EAC3C,CAACR,EAAWS,CAAY,EAAIC,EAAAA,SAAwB,IAAI,EAE9DC,OAAAA,EAAAA,UAAU,IAAM,CACd,GAAI,GAACnB,GAAU,CAACC,GAChB,OAAOF,EAAUC,EAAQC,EAAMgB,CAAY,CAC7C,EAAG,CAACjB,EAAQC,CAAI,CAAC,EAEV,CAAE,UAAAO,CAAA,CACX"}
|
package/dist/web/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Pi-Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-IJCh_DKC.js"></script>
|
|
8
8
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-CWo7Do4s.js">
|
|
9
9
|
<link rel="modulepreload" crossorigin href="/assets/vendor-query-BGIh-vlp.js">
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vendor-markdown-YvkQLm_s.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@av-pi-studio/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.68",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist"
|
|
6
6
|
],
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@av-pi-studio/client": "^0.0.
|
|
18
|
-
"@av-pi-studio/protocol": "^0.0.
|
|
17
|
+
"@av-pi-studio/client": "^0.0.68",
|
|
18
|
+
"@av-pi-studio/protocol": "^0.0.68",
|
|
19
19
|
"@codemirror/language": "^6.12.4",
|
|
20
20
|
"@codemirror/language-data": "^6.5.2",
|
|
21
21
|
"@codemirror/state": "^6.7.1",
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{r as n,j as r}from"./vendor-react-CWo7Do4s.js";import{a as V,P as c,E as _,S as A,h as F}from"./index-KQwi4ZXj.js";import{m as L}from"./vendor-molviewer-DE5Dt5UM.js";import{S}from"./StatusBadge-DFSeaZ3p.js";import{u as N}from"./use-file-download-Dv5HRkkB.js";import{u as T}from"./use-file-watch-DTOtoP8W.js";import"./vendor-query-BGIh-vlp.js";import"./vendor-04vXFIXe.js";import"./vendor-markdown-YvkQLm_s.js";import"./vendor-highlight-Efc6A-g6.js";import"./vendor-icons-CJ_dOXcR.js";import"./vendor-overlays-BnmaUbKw.js";import"./vendor-dnd-Bj2_GhfD.js";function P(e,o){return!e||!o?null:{url:o,name:e.split("/").pop()||e}}function B({changedAt:e,lastAppliedAt:o,modified:t}){return!(e===null||t||e===o)}const k={empty_path:"Nothing to save.",too_large:"File is too large to save (over 5 MiB).",not_found:"The file no longer exists.",unreadable:"The file could not be read.",not_a_file:"That path is not a file."};async function q(e,o,t){const l=await e.connection.request("file_write_request",{path:o,content:t});if(!l.ok){const s=l.error??"";throw new Error(k[s]??s??"Failed to save")}}const I={"--canvas":"var(--pi-color-surface0)","--well":"var(--pi-color-surface0)","--recess":"var(--pi-color-surface1)","--chrome":"var(--pi-color-surface1)","--control":"var(--pi-color-surface2)","--control-hover":"var(--pi-color-surface3)","--control-strong-hover":"var(--pi-color-surface4)","--border-control":"var(--pi-color-border)","--border-input":"var(--pi-color-border)","--popover":"var(--pi-color-surface1)","--popover-item-hover":"var(--pi-color-surface2)","--row-hover":"var(--pi-color-surface2)","--border-popover":"var(--pi-color-border)","--text-primary":"var(--pi-color-foreground)","--text-on-control":"var(--pi-color-foreground)"},O="_wrap_tixu9_1",W="_molViewer_tixu9_4",z="_badges_tixu9_8",m={wrap:O,molViewer:W,badges:z};function D({path:e,isActive:o,onModifiedChange:t}){var E;const l=V(a=>a.client),s=N(e??"",!!e),{changedAt:i}=T(e),b=n.useRef(null),v=n.useRef(!1),[u,w]=n.useState(null),[d,j]=n.useState(!1),[x,f]=n.useState(null),p=n.useRef(null),R=P(e,(E=s.data)==null?void 0:E.objectUrl);n.useEffect(()=>{B({changedAt:i,lastAppliedAt:p.current,modified:d})&&(p.current=i,s.refetch())},[i,d,s.refetch]);const y=i!==null&&i!==p.current&&d;async function M(a){if(e){if(!l){f("Not connected — cannot save.");return}f(null);try{await q(l,e,a.text()),a.saved()}catch(g){f(g instanceof Error?g.message:"Failed to save")}}}const h=(s.isError?s.error instanceof Error?s.error.message:"download failed":null)??(u==null?void 0:u.message)??null;return e&&s.isPending?r.jsx(c,{children:r.jsxs(_,{children:[r.jsx(A,{size:"sm"})," Loading..."]})}):h?r.jsx(c,{children:r.jsxs(_,{children:["Error: ",h]})}):r.jsxs(c,{className:m.wrap,"data-molecule-active":o?"true":"false",children:[r.jsxs("div",{className:m.badges,children:[y&&r.jsx(S,{label:"File changed on disk",variant:"muted"}),x&&r.jsx(S,{label:`Save failed: ${x}`,variant:"error"})]}),r.jsx(L,{ref:b,className:m.molViewer,theme:I,source:R,sourceMode:v.current?"update":"replace",onLoad:()=>{v.current=!0,w(null)},onLoadError:a=>w(a.error),onModifiedChange:a=>{j(a),t==null||t(a)},onSave:e?M:void 0})]})}function se({tab:e}){const{path:o}=e.data,t=F(e.id);return r.jsx(c,{children:r.jsx(D,{path:o,isActive:t})})}export{se as MoleculeViewerPanel};
|
|
2
|
-
//# sourceMappingURL=MoleculeViewerPanel-CQQ9CIsD.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"MoleculeViewerPanel-CQQ9CIsD.js","sources":["../../../src/features/files/molecule-source.ts","../../../src/features/files/molecule-reload.ts","../../../src/features/files/write-file.ts","../../../src/features/files/molecule-theme.ts","../../../src/features/files/MoleculeViewer.tsx","../../../src/features/files/MoleculeViewerPanel.tsx"],"sourcesContent":["/**\n * Pure derivation of molviewer's `{ url, name }` source shape, kept in its own module (no\n * `@molviewer/core` import) so it is unit-testable without pulling in the WebGL viewer bundle —\n * merely importing `@molviewer/core` touches `document` at module scope, which jsdom-less/node\n * test environments don't have.\n */\nimport type { MolViewerSource } from \"@molviewer/core\";\n\n/** `name` is load-bearing: molviewer resolves the file format from its extension. */\nexport function moleculeSource(\n path: string | null,\n objectUrl: string | null | undefined,\n): MolViewerSource | null {\n if (!path || !objectUrl) return null;\n return { url: objectUrl, name: path.split(\"/\").pop() || path };\n}\n","/**\n * Pure reload-gating decision for the molecule viewer's live reload (task-006/007). Kept in its\n * own module — like `molecule-source.ts` — so it is unit-testable without importing\n * `@molviewer/core` (which touches `document` at module scope).\n *\n * The gate is intentionally just \"are there unsaved in-viewer edits right now\" — no timestamp or\n * echo-suppression window. A future in-viewer \"Save\" cannot self-trigger a clobber: a save can\n * only happen while `modified` is `true`, and the earliest it flips back to `false` is after the\n * write completes — at which point a reload triggered by that same write is a content no-op.\n */\nexport interface ShouldApplyRefreshArgs {\n /** Timestamp of the last `file_changed` push for this path, or `null` if none yet. */\n changedAt: number | null;\n /** Timestamp of the last push this component actually reloaded for, or `null`. */\n lastAppliedAt: number | null;\n /** Whether the viewer currently has unsaved in-viewer edits. */\n modified: boolean;\n}\n\nexport function shouldApplyRefresh({\n changedAt,\n lastAppliedAt,\n modified,\n}: ShouldApplyRefreshArgs): boolean {\n if (changedAt === null) return false; // No change pushed yet.\n if (modified) return false; // Unsaved edits — leave the user's work alone.\n if (changedAt === lastAppliedAt) return false; // Already applied this push — no reload loop.\n return true;\n}\n","/**\n * Shared `file_write_request` caller for save flows (currently the molecule viewer's `onSave`,\n * `MoleculeViewer.tsx`) — one place for the wire call and the server error-code → user-facing\n * message mapping, structured the same way as the sibling `move-entry.ts`. Overwrite-only: the\n * daemon 404s `not_found` for a missing target (`FileExplorerService.writeFile`); there is no\n * create-on-write.\n */\n\nimport type { PiStudioClient } from \"@av-pi-studio/client\";\n\nconst ERROR_MESSAGES: Record<string, string> = {\n empty_path: \"Nothing to save.\",\n too_large: \"File is too large to save (over 5 MiB).\",\n not_found: \"The file no longer exists.\",\n unreadable: \"The file could not be read.\",\n not_a_file: \"That path is not a file.\",\n};\n\n/** Overwrite `path`'s content on disk. Throws an Error carrying a user-facing message on\n * failure. */\nexport async function writeFile(\n client: PiStudioClient,\n path: string,\n content: string,\n): Promise<void> {\n const response = await client.connection.request<{ ok: boolean; error?: string }>(\n \"file_write_request\",\n { path, content },\n );\n if (!response.ok) {\n const code = response.error ?? \"\";\n throw new Error(ERROR_MESSAGES[code] ?? code ?? \"Failed to save\");\n }\n}\n","/**\n * MOLVIEWER_THEME — pi-studio's background/foreground override for `<MolViewer>`'s `theme` prop.\n *\n * `theme` is documented as raw CSS custom properties applied inline to the component's root\n * (`docs/molviewer-core-doc.md` § UI composition) — a completely separate knob from\n * `initialView.backgroundColor` (the WebGL scene's clear color) and `colorScheme` (per-atom\n * coloring). Neither of those is touched here; this only changes chrome DOM elements (sidebar,\n * toolbars, panels, buttons, dropdowns), never the rendered molecule itself.\n *\n * Values are `var(--pi-color-*)` references, not resolved hex — a CSS custom property may hold\n * another `var()` reference and it chains through the cascade like any other value, so\n * molviewer's chrome tracks pi-studio's live theme (light/dark, custom accent) automatically,\n * with no React re-render needed when appearance settings change.\n *\n * Scope is deliberately narrow, per product decision: panel backgrounds, primary text, and —\n * where molviewer exposes an equivalent — the surfaces of its own interactive controls (buttons)\n * and popovers (dropdowns/menus), all mapped onto pi-studio's existing surface/hover/border scale\n * (`Button.module.css`, `Select.module.css`, the `.item[data-highlighted]` hover pattern used\n * across every pi-studio dropdown). Molviewer's semantic/interactive colors that pi-studio has no\n * equivalent for — accent, selection, danger/success/warning, its five-step muted text scale,\n * axis colors — are its own design language and are left untouched.\n *\n * Molviewer's shipped defaults, for reference (`@molviewer/core/dist/style.css`'s `:root` block):\n * --canvas #0a0c10 --well #0e1116 --recess #0f1217 --chrome #12151b\n * --control #1e232c --control-hover #262c37 --control-strong-hover #2b323e\n * --popover #171b22 --popover-item-hover #232935 --row-hover #161a21\n * --border-control #333b48 --border-input #2b323e --border-popover #3b4453\n * --text-primary #e6e9ef --text-on-control #dfe5ee\n */\nexport const MOLVIEWER_THEME: Record<string, string> = {\n // Panel backgrounds (sidebar, toolbars, recessed panels).\n \"--canvas\": \"var(--pi-color-surface0)\",\n \"--well\": \"var(--pi-color-surface0)\",\n \"--recess\": \"var(--pi-color-surface1)\",\n \"--chrome\": \"var(--pi-color-surface1)\",\n // Buttons and other controls.\n \"--control\": \"var(--pi-color-surface2)\",\n \"--control-hover\": \"var(--pi-color-surface3)\",\n \"--control-strong-hover\": \"var(--pi-color-surface4)\",\n \"--border-control\": \"var(--pi-color-border)\",\n \"--border-input\": \"var(--pi-color-border)\",\n // Dropdowns / menus.\n \"--popover\": \"var(--pi-color-surface1)\",\n \"--popover-item-hover\": \"var(--pi-color-surface2)\",\n \"--row-hover\": \"var(--pi-color-surface2)\",\n \"--border-popover\": \"var(--pi-color-border)\",\n // Text.\n \"--text-primary\": \"var(--pi-color-foreground)\",\n \"--text-on-control\": \"var(--pi-color-foreground)\",\n};\n","/**\n * MoleculeViewer — mounts `<MolViewer>` for both molecule-tab shapes: a file-backed tab (fetch\n * the file, hand molviewer the bytes) and an empty (\"+\"-menu) tab (no source — molviewer's own\n * drag-drop empty state, `ui.emptyState`'s `FirstRunCard`).\n *\n * Content is fetched through `useFileDownload` (chunked binary transfer, uncapped), not\n * `useFileRead` (`file_read_request`, UTF-8 text, inline-size capped): molviewer wants raw bytes\n * and the object URL that hook already produces is exactly molviewer's `{ url, name }` source\n * shape — no decode step, no new fetch hook.\n *\n * Live reload (task-006/007): `useFileWatch` pushes a new `changedAt` whenever the daemon detects\n * the file changed on disk. `shouldApplyRefresh` (kept in its own pure module, see\n * `molecule-reload.ts`) gates the reaction on there being no unsaved in-viewer edits; when it\n * clears, `download.refetch()` mints a fresh object URL, the `source` prop changes, and\n * `MolViewer` reloads with `sourceMode=\"update\"` — camera/selection survive. When it's gated by\n * unsaved edits, a small stale-file indicator surfaces instead of silently diverging.\n *\n * Save (`onSave`, `@molviewer/core@0.4.1`): only wired when `path` is non-null — a file-backed\n * tab writes the viewer's current serialization straight back to that same absolute path via\n * `file_write_request` (`write-file.ts`); the empty (\"+\"-menu) tab has nowhere to write and gets\n * no Save button at all (passing `onSave` is what draws it). `e.saved()` is called only after the\n * daemon confirms the write, which is what greys the button out and flips the viewer back to\n * clean — a failed write leaves it live so the user can retry. Note `e.fileName` is molviewer's\n * own load-time basename (`moleculeSource`'s `name`, extension only, no directory), not a usable\n * write target — the absolute `path` prop already in scope is what gets written.\n */\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { MolViewer, type MolViewerHandle, type SaveEvent } from \"@molviewer/core\";\nimport \"@molviewer/core/style.css\";\nimport { Spinner } from \"@pi-studio-ui/components/primitives/Spinner.js\";\nimport { StatusBadge } from \"@pi-studio-ui/components/primitives/StatusBadge.js\";\nimport { Panel } from \"@pi-studio-ui/components/primitives/Panel.js\";\nimport { EmptyState } from \"@pi-studio-ui/components/primitives/EmptyState.js\";\nimport { useConnectionStore } from \"@pi-studio-ui/lib/connection/connection-store.js\";\nimport { useFileDownload } from \"@pi-studio-ui/hooks/use-file-download.js\";\nimport { useFileWatch } from \"@pi-studio-ui/hooks/use-file-watch.js\";\nimport { moleculeSource } from \"./molecule-source.js\";\nimport { shouldApplyRefresh } from \"./molecule-reload.js\";\nimport { writeFile } from \"./write-file.js\";\nimport { MOLVIEWER_THEME } from \"./molecule-theme.js\";\nimport styles from \"./MoleculeViewer.module.css\";\n\nexport interface MoleculeViewerProps {\n /** Absolute path of the file to render, or null for an empty (\"New molecule view\") tab. */\n path: string | null;\n /** True when this viewer's tab is the visible one. Molstar's own `ResizeObserver` on its canvas\n * container re-fits on becoming visible (verified in the installed bundle), so this is not\n * currently used to drive a manual re-fit — kept as the escape hatch task-010's visual\n * verification may still need. */\n isActive?: boolean;\n /** Mirrors `MolViewerProps.onModifiedChange` upward for a future parent-level consumer (e.g. a\n * tab-strip \"unsaved changes\" mark) — independent of this component's own internal `modified`\n * state below, which drives the live-reload gate. */\n onModifiedChange?: (modified: boolean) => void;\n}\n\nexport function MoleculeViewer({ path, isActive, onModifiedChange }: MoleculeViewerProps) {\n const client = useConnectionStore((s) => s.client);\n const download = useFileDownload(path ?? \"\", Boolean(path));\n const { changedAt } = useFileWatch(path);\n const handleRef = useRef<MolViewerHandle>(null);\n // First load of this tab's file refits the camera (\"replace\"); every reload after that (the\n // same file changing on disk, task-007) preserves camera/selection/undo (\"update\"). A mounted\n // MoleculeViewer corresponds to exactly one tab for its whole lifetime (task-004 mints a new\n // tab id per path), so this never needs to reset for a \"new\" path mid-lifetime.\n const hasLoadedRef = useRef(false);\n const [loadError, setLoadError] = useState<Error | null>(null);\n const [modified, setModified] = useState(false);\n const [saveError, setSaveError] = useState<string | null>(null);\n // Ref, not state: recording \"which push we last acted on\" doesn't need its own render — it's\n // read by the same effect that writes it, and by the stale-indicator check below.\n const lastAppliedAtRef = useRef<number | null>(null);\n\n const source = moleculeSource(path, download.data?.objectUrl);\n\n useEffect(() => {\n if (!shouldApplyRefresh({ changedAt, lastAppliedAt: lastAppliedAtRef.current, modified })) {\n return;\n }\n lastAppliedAtRef.current = changedAt;\n void download.refetch();\n // Only `download.refetch` itself needs to be stable across renders for this effect to behave\n // correctly; depending on the whole `download` query object would re-run on every unrelated\n // status change it produces.\n }, [changedAt, modified, download.refetch]);\n\n // A push arrived but couldn't be applied because of unsaved edits — surface that rather than\n // silently diverging. Reading the ref during render is safe here: it only ever changes inside\n // the effect above, which always runs before the next paint that could observe a stale value.\n const hasUnappliedChange =\n changedAt !== null && changedAt !== lastAppliedAtRef.current && modified;\n\n async function handleSave(e: SaveEvent) {\n if (!path) return;\n if (!client) {\n setSaveError(\"Not connected — cannot save.\");\n return;\n }\n setSaveError(null);\n try {\n await writeFile(client, path, e.text());\n e.saved();\n } catch (err) {\n setSaveError(err instanceof Error ? err.message : \"Failed to save\");\n }\n }\n\n const downloadErrorMessage = download.isError\n ? download.error instanceof Error\n ? download.error.message\n : \"download failed\"\n : null;\n const errorMessage = downloadErrorMessage ?? loadError?.message ?? null;\n\n if (path && download.isPending) {\n return (\n <Panel>\n <EmptyState>\n <Spinner size=\"sm\" /> Loading...\n </EmptyState>\n </Panel>\n );\n }\n if (errorMessage) {\n return (\n <Panel>\n <EmptyState>Error: {errorMessage}</EmptyState>\n </Panel>\n );\n }\n\n return (\n <Panel className={styles.wrap} data-molecule-active={isActive ? \"true\" : \"false\"}>\n <div className={styles.badges}>\n {hasUnappliedChange && <StatusBadge label=\"File changed on disk\" variant=\"muted\" />}\n {saveError && <StatusBadge label={`Save failed: ${saveError}`} variant=\"error\" />}\n </div>\n <MolViewer\n ref={handleRef}\n className={styles.molViewer}\n theme={MOLVIEWER_THEME}\n source={source}\n sourceMode={hasLoadedRef.current ? \"update\" : \"replace\"}\n onLoad={() => {\n hasLoadedRef.current = true;\n setLoadError(null);\n }}\n onLoadError={(e) => setLoadError(e.error)}\n onModifiedChange={(m) => {\n setModified(m);\n onModifiedChange?.(m);\n }}\n onSave={path ? handleSave : undefined}\n />\n </Panel>\n );\n}\n","/**\n * MoleculeViewerPanel — thin `PanelProps` adapter for a `kind: \"molecule\"` tab. No header, no\n * File/Diff toggle (a molecule tab has neither — diffs come from the git Changes panel\n * independently, `ChangesPanel.tsx`); it owns the panel-filling height chain and hands off to\n * `MoleculeViewer` for everything else.\n */\n\nimport { useIsTabVisible, type MoleculeTabData } from \"@pi-studio-ui/stores/tab-store.js\";\nimport type { PanelProps } from \"@pi-studio-ui/features/workspace/panel-registry.js\";\nimport { Panel } from \"@pi-studio-ui/components/primitives/Panel.js\";\nimport { MoleculeViewer } from \"./MoleculeViewer.js\";\n\nexport function MoleculeViewerPanel({ tab }: PanelProps) {\n const { path } = tab.data as MoleculeTabData;\n // Per-pane visibility: a molecule tab in a non-focused pane is still on screen and must resize.\n const isActive = useIsTabVisible(tab.id);\n\n return (\n <Panel>\n <MoleculeViewer path={path} isActive={isActive} />\n </Panel>\n );\n}\n"],"names":["moleculeSource","path","objectUrl","shouldApplyRefresh","changedAt","lastAppliedAt","modified","ERROR_MESSAGES","writeFile","client","content","response","code","MOLVIEWER_THEME","MoleculeViewer","isActive","onModifiedChange","useConnectionStore","s","download","useFileDownload","useFileWatch","handleRef","useRef","hasLoadedRef","loadError","setLoadError","useState","setModified","saveError","setSaveError","lastAppliedAtRef","source","_a","useEffect","hasUnappliedChange","handleSave","e","err","errorMessage","jsx","Panel","jsxs","EmptyState","Spinner","styles","StatusBadge","MolViewer","m","MoleculeViewerPanel","tab","useIsTabVisible"],"mappings":"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"}
|