paseo-wiki-viewer 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 anh-chu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/OVERVIEW.md ADDED
@@ -0,0 +1,22 @@
1
+ Wiki Viewer opens a Paseo workspace in [wiki-viewer](https://www.npmjs.com/package/wiki-viewer), a local file viewer for markdown, HTML, PDFs, notebooks, office documents, and more.
2
+
3
+ Search for "Open Wiki Viewer" in the Command Center to open it in the Explorer sidebar, or add the **Wiki Viewer** panel to a workspace. The panel installs or updates wiki-viewer from npm when you press its button; it never downloads anything on its own. On desktop and in the browser the wiki shows inside the panel, with **Open in tab** for a full browser tab. On iOS and Android the panel opens it in your system browser.
4
+
5
+ On desktop and in the browser, clicking a file path in an agent's chat opens that file in the Wiki Viewer sidebar instead of a Paseo file tab. Alt-click (Option-click) keeps Paseo's file tab. A bare or partial name such as `panel.tsx` opens the most recently modified workspace file whose path ends with it. Links that match no file in the workspace still go to Paseo.
6
+
7
+ ## How it works
8
+
9
+ The daemon runs wiki-viewer on `127.0.0.1` and puts a small access-checking proxy in front of it. Each open link carries a random grant tied to one workspace directory; the proxy rejects requests without a valid grant and fixes the browsed root to that directory. Because a frame inside Paseo cannot keep cookies, the proxy adds a small script to each viewer page that attaches the grant to the page's own requests. A grant expires after 30 minutes without use.
10
+
11
+ ## Settings
12
+
13
+ Under Settings, Plugins, Wiki Viewer:
14
+
15
+ - **Open chat file links in Wiki Viewer**: on by default. Turn it off to leave chat links to Paseo's file tab.
16
+ - **Public URL**: the base URL your devices use to reach the daemon. Empty uses `http://<daemon hostname>:<port>`. Set it when the daemon is reached through another name, a tunnel, or HTTPS.
17
+ - **Port**: the proxy port. 0 picks a free port at each start; pin it if a firewall must allow it.
18
+ - **Viewer root**: run an existing wiki-viewer build or source checkout instead of the managed install in `~/.paseo/wiki-viewer`.
19
+
20
+ ## Limits
21
+
22
+ The proxy listens on all interfaces of the daemon machine and uses plain HTTP, so anyone on that network who obtains a grant URL can read that workspace until the grant expires. The managed install needs `npm` and `tar` on the daemon's `PATH` and is about 30 MB. Looking up bare file names needs `rg` (ripgrep) on the daemon's `PATH`; the plugin keeps a list of each workspace's files in memory, and in a very large workspace the first lookup can take several seconds.
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # Wiki Viewer for Paseo
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [Paseo](https://paseo.sh) plugin that shows a workspace in [wiki-viewer](https://www.npmjs.com/package/wiki-viewer) inside the Explorer sidebar, and opens file paths clicked in agent chat there. See [OVERVIEW.md](OVERVIEW.md) for what it does, its settings, and its limits.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ paseo plugin install npm:paseo-wiki-viewer
9
+ ```
10
+
11
+ Or from source:
12
+
13
+ ```bash
14
+ paseo plugin install github:anh-chu/paseo-wiki-viewer
15
+ ```
16
+
17
+ Then open **Wiki Viewer** from the Command Center (Ctrl+K or ⌘K) and press **Install wiki-viewer** once.
18
+
19
+ Requirements on the daemon machine: Paseo 0.11.2 or newer, `npm` and `tar` for the viewer install, and `rg` (ripgrep) for chat file-name lookup.
20
+
21
+ ## Develop
22
+
23
+ ```bash
24
+ npm install
25
+ npm run typecheck
26
+ npm test
27
+ paseo plugin install "$PWD"
28
+ paseo plugin reload wiki-viewer
29
+ ```
30
+
31
+ The plugin is a port of `dsh-wiki-viewer` from [anh-chu/dsh-plugins](https://github.com/anh-chu/dsh-plugins).
32
+
33
+ ## License
34
+
35
+ MIT
Binary file
Binary file
Binary file
@@ -0,0 +1,30 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ export interface OpenRequest {
4
+ workspaceId: string;
5
+ path: string;
6
+ seq: number;
7
+ at: number;
8
+ }
9
+
10
+ let latest: OpenRequest | null = null;
11
+ let seq = 0;
12
+ const listeners = new Set<() => void>();
13
+
14
+ // Chat link clicks land here; the Wiki Viewer panel for that workspace picks them up.
15
+ export function requestOpen(workspaceId: string, path: string) {
16
+ latest = { workspaceId, path, seq: ++seq, at: Date.now() };
17
+ for (const listener of listeners) listener();
18
+ }
19
+
20
+ function subscribe(listener: () => void) {
21
+ listeners.add(listener);
22
+ return () => {
23
+ listeners.delete(listener);
24
+ };
25
+ }
26
+
27
+ export function useOpenRequest(workspaceId: string): OpenRequest | null {
28
+ const request = useSyncExternalStore(subscribe, () => latest);
29
+ return request?.workspaceId === workspaceId ? request : null;
30
+ }
@@ -0,0 +1,202 @@
1
+ import { openExternalUrl, useRpc, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Pressable, ScrollView, Text, View } from "react-native";
5
+ import { describeWikiVersion, prepareWiki, updateWiki } from "../shared/wiki";
6
+ import { useOpenRequest } from "./open-requests";
7
+ import { canEmbed, postOpenFile, WikiFrame } from "./web";
8
+
9
+ // Grants live 30 minutes after their last use. Refreshing every 10 keeps the
10
+ // URL ready, so a press opens it synchronously (browsers block a new tab
11
+ // opened after an awaited request).
12
+ const PREPARE_INTERVAL_MS = 10 * 60 * 1000;
13
+ // A chat link that opened this panel counts only if it arrived just now.
14
+ const FRESH_REQUEST_MS = 10_000;
15
+ const READY_DELAY_MS = 1500;
16
+
17
+ function message(error: unknown, fallback: string) {
18
+ return error instanceof Error ? error.message : fallback;
19
+ }
20
+
21
+ export function WikiPanel({ theme, layout, workspaceId, navigation }: PluginWorkspacePanelProps) {
22
+ const queryClient = useQueryClient();
23
+ const describeVersion = useRpc(describeWikiVersion);
24
+ const prepare = useRpc(prepareWiki);
25
+ const update = useRpc(updateWiki);
26
+
27
+ const version = useQuery({ queryKey: ["wiki", "version"], queryFn: () => describeVersion({}) });
28
+ const installed = version.data?.installed != null;
29
+ const target = useQuery({
30
+ queryKey: ["wiki", "prepare", workspaceId],
31
+ queryFn: () => prepare({ workspaceId }),
32
+ enabled: installed,
33
+ refetchInterval: PREPARE_INTERVAL_MS,
34
+ retry: false,
35
+ });
36
+ const install = useMutation({
37
+ mutationFn: () => update({}),
38
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["wiki"] }),
39
+ });
40
+
41
+ const styles = useMemo(
42
+ () => ({
43
+ screen: { flex: 1, backgroundColor: theme.colors.surface0 },
44
+ body: { padding: layout.compact ? 16 : 24, gap: 12 },
45
+ title: { color: theme.colors.foreground, fontSize: layout.compact ? 18 : 20, fontWeight: "600" as const },
46
+ muted: { color: theme.colors.foregroundMuted, fontSize: 13 },
47
+ error: { color: theme.colors.statusDanger, fontSize: 13 },
48
+ row: { flexDirection: "row" as const, justifyContent: "space-between" as const, gap: 8 },
49
+ value: { color: theme.colors.foreground, fontSize: 13, fontFamily: "monospace", flexShrink: 1 },
50
+ actions: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 8 },
51
+ primary: { paddingVertical: 10, paddingHorizontal: 14, borderRadius: 8, backgroundColor: theme.colors.accent },
52
+ primaryText: { color: theme.colors.accentForeground, fontWeight: "600" as const },
53
+ secondary: { paddingVertical: 10, paddingHorizontal: 14, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1 },
54
+ secondaryText: { color: theme.colors.foreground },
55
+ disabled: { opacity: 0.5 },
56
+ toolbar: { padding: 6, borderBottomWidth: 1, borderColor: theme.colors.border },
57
+ small: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 6, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1 },
58
+ }),
59
+ [theme, layout.compact],
60
+ );
61
+
62
+ const info = version.data;
63
+ const url = target.data?.url;
64
+ // The frame keeps its first URL: the grant stays alive while the frame uses
65
+ // it, and swapping in each refreshed URL would reload the page.
66
+ const [frameUrl, setFrameUrl] = useState<string | undefined>();
67
+ // The viewer listens for open requests only once it has started, a moment after the frame loads.
68
+ const [frameReady, setFrameReady] = useState(false);
69
+ const [pendingPath, setPendingPath] = useState<string | null>(null);
70
+ const frameRef = useRef<unknown>(null);
71
+ const request = useOpenRequest(workspaceId);
72
+ // A chat link that just opened this panel still needs showing; older ones were seen elsewhere.
73
+ const handledSeq = useRef(request && Date.now() - request.at < FRESH_REQUEST_MS ? request.seq - 1 : (request?.seq ?? 0));
74
+ const showFrame = (next: string) => {
75
+ setFrameReady(false);
76
+ setFrameUrl(next);
77
+ };
78
+ useEffect(() => {
79
+ if (!request || request.seq <= handledSeq.current) return;
80
+ handledSeq.current = request.seq;
81
+ if (frameUrl && frameReady) postOpenFile(frameRef.current, frameUrl, request.path);
82
+ else setPendingPath(request.path);
83
+ }, [request, frameUrl, frameReady]);
84
+ useEffect(() => {
85
+ if (!url) return;
86
+ if (pendingPath !== null) {
87
+ // Not loaded yet: start the frame on the requested file instead.
88
+ const withFile = new URL(url);
89
+ withFile.searchParams.set("file", pendingPath);
90
+ setPendingPath(null);
91
+ showFrame(withFile.toString());
92
+ } else if (!frameUrl) {
93
+ showFrame(url);
94
+ }
95
+ }, [url, frameUrl, pendingPath]);
96
+ const openBrowser = navigation?.openBrowser;
97
+ const open = () => {
98
+ if (!url) return;
99
+ if (openBrowser) openBrowser({ url, workspaceId });
100
+ else void openExternalUrl(url);
101
+ };
102
+
103
+ const installLabel = install.isPending
104
+ ? "Installing…"
105
+ : info?.installed == null
106
+ ? "Install wiki-viewer"
107
+ : info.latest !== null && info.updateAvailable
108
+ ? `Update to ${info.latest}`
109
+ : "Up to date";
110
+ const canInstall = !install.isPending && info?.managed === true && (info.installed === null || info.updateAvailable);
111
+
112
+ if (canEmbed && frameUrl) {
113
+ return (
114
+ <View style={styles.screen}>
115
+ <View style={[styles.actions, styles.toolbar]}>
116
+ <Pressable accessibilityRole="button" onPress={() => {
117
+ // A fresh prepare restarts a dead viewer and yields a new grant, so the frame remounts.
118
+ void target.refetch().then(({ data }) => data && showFrame(data.url));
119
+ }} style={styles.small}>
120
+ <Text style={styles.secondaryText}>Reload</Text>
121
+ </Pressable>
122
+ <Pressable accessibilityRole="button" onPress={open} style={styles.small}>
123
+ <Text style={styles.secondaryText}>Open in tab</Text>
124
+ </Pressable>
125
+ </View>
126
+ <WikiFrame key={frameUrl} url={frameUrl} title="Wiki Viewer" frameRef={frameRef} onLoad={() => setTimeout(() => setFrameReady(true), READY_DELAY_MS)} />
127
+ </View>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <ScrollView style={styles.screen} contentContainerStyle={styles.body}>
133
+ <Text style={styles.title}>Wiki Viewer</Text>
134
+ <Text style={styles.muted}>Browse this workspace's files in wiki-viewer.</Text>
135
+
136
+ {version.isPending ? <Text style={styles.muted}>Checking versions…</Text> : null}
137
+ {version.error ? <Text style={styles.error}>{message(version.error, "Version check failed.")}</Text> : null}
138
+
139
+ {info ? (
140
+ <View style={{ gap: 4 }}>
141
+ <View style={styles.row}>
142
+ <Text style={styles.muted}>Installed</Text>
143
+ <Text style={styles.value}>{info.installed ?? "not installed"}</Text>
144
+ </View>
145
+ <View style={styles.row}>
146
+ <Text style={styles.muted}>Latest release</Text>
147
+ <Text style={styles.value}>{info.latest ?? (info.latestError !== "" ? `check failed (${info.latestError})` : "unknown")}</Text>
148
+ </View>
149
+ <View style={styles.row}>
150
+ <Text style={styles.muted}>Location</Text>
151
+ <Text style={styles.value}>{info.root}</Text>
152
+ </View>
153
+ {info.managed ? null : <Text style={styles.muted}>A custom viewer root is set in Settings; update that install manually.</Text>}
154
+ {info.checkout ? <Text style={styles.muted}>Source checkout without a production build. Build it there first.</Text> : null}
155
+ </View>
156
+ ) : null}
157
+
158
+ {target.error ? <Text style={styles.error}>{message(target.error, "Wiki Viewer unavailable.")}</Text> : null}
159
+ {install.error ? <Text style={styles.error}>{message(install.error, "Install failed.")}</Text> : null}
160
+ {install.data ? (
161
+ <Text style={styles.muted}>
162
+ {install.data.updated ? `Installed wiki-viewer ${install.data.version}.` : `Already on the latest release (${install.data.version ?? "unknown"}).`}
163
+ </Text>
164
+ ) : null}
165
+
166
+ <View style={styles.actions}>
167
+ {installed ? (
168
+ <Pressable
169
+ accessibilityRole="button"
170
+ accessibilityLabel="Open the workspace in Wiki Viewer"
171
+ disabled={!url}
172
+ onPress={open}
173
+ style={[styles.primary, !url && styles.disabled]}
174
+ >
175
+ <Text style={styles.primaryText}>{target.isPending ? "Starting Wiki Viewer…" : "Open wiki"}</Text>
176
+ </Pressable>
177
+ ) : null}
178
+ {target.error ? (
179
+ <Pressable accessibilityRole="button" onPress={() => void target.refetch()} style={styles.secondary}>
180
+ <Text style={styles.secondaryText}>Retry</Text>
181
+ </Pressable>
182
+ ) : null}
183
+ {info ? (
184
+ <Pressable
185
+ accessibilityRole="button"
186
+ disabled={!canInstall}
187
+ onPress={() => install.mutate()}
188
+ style={[styles.secondary, !canInstall && styles.disabled]}
189
+ >
190
+ <Text style={styles.secondaryText}>{installLabel}</Text>
191
+ </Pressable>
192
+ ) : null}
193
+ {version.error ? (
194
+ <Pressable accessibilityRole="button" onPress={() => void version.refetch()} style={styles.secondary}>
195
+ <Text style={styles.secondaryText}>Retry</Text>
196
+ </Pressable>
197
+ ) : null}
198
+ </View>
199
+ {url && !openBrowser ? <Text style={styles.muted}>Opens in your browser. The link reaches the daemon machine over the network.</Text> : null}
200
+ </ScrollView>
201
+ );
202
+ }
@@ -0,0 +1,75 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { SettingsAction, SettingsCard, SettingsInput, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
3
+ import { useState } from "react";
4
+ import { Text } from "react-native";
5
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
6
+ import { wikiSettings } from "../shared/wiki";
7
+
8
+ export function WikiSettings({ theme }: PluginSurfaceProps) {
9
+ const settings = useSettings(wikiSettings);
10
+ if (settings.status !== "ready") {
11
+ const text = settings.status === "loading" ? "Loading…" : String(settings.error);
12
+ return <Text style={{ color: theme.colors.foregroundMuted }}>{text}</Text>;
13
+ }
14
+ return <Editor key={settings.revision} settings={settings} />;
15
+ }
16
+
17
+ type ReadySettings = Extract<ReturnType<typeof useSettings<typeof wikiSettings.schema>>, { status: "ready" }>;
18
+
19
+ function Editor({ settings }: { settings: ReadySettings }) {
20
+ const [publicUrl, setPublicUrl] = useState(settings.values.publicUrl);
21
+ const [port, setPort] = useState(String(settings.values.port));
22
+ const [viewerRoot, setViewerRoot] = useState(settings.values.viewerRoot);
23
+ const portValue = Number(port);
24
+ const portError = Number.isInteger(portValue) && portValue >= 0 && portValue <= 65535 ? undefined : "Enter 0 to 65535.";
25
+ const urlError = publicUrl === "" || /^https?:\/\/\S+$/.test(publicUrl) ? undefined : "Enter an http(s) URL.";
26
+
27
+ return (
28
+ <>
29
+ <SettingsSection title="Chat">
30
+ <SettingsCard>
31
+ <SettingsSwitch
32
+ label="Open chat file links in Wiki Viewer"
33
+ hint="Clicking a file path in an agent's chat shows it in the sidebar. Alt-click always opens Paseo's file tab."
34
+ value={settings.values.openChatLinks}
35
+ disabled={settings.saving}
36
+ onValueChange={(openChatLinks) => void settings.save({ ...settings.values, openChatLinks }, settings.revision)}
37
+ />
38
+ </SettingsCard>
39
+ </SettingsSection>
40
+ <SettingsSection title="Connection">
41
+ <SettingsCard>
42
+ <SettingsInput
43
+ label="Public URL"
44
+ hint="Base URL your devices use to reach the daemon, for example http://devvm:7791. Empty uses http://<daemon hostname>:<port>."
45
+ initialValue={publicUrl}
46
+ placeholder="http://hostname:port"
47
+ error={urlError}
48
+ onChangeText={setPublicUrl}
49
+ />
50
+ <SettingsInput
51
+ label="Port"
52
+ hint="Port of the access-checking proxy on the daemon machine. 0 picks a free port each start."
53
+ initialValue={port}
54
+ error={portError}
55
+ onChangeText={setPort}
56
+ />
57
+ <SettingsInput
58
+ label="Viewer root"
59
+ hint="A built wiki-viewer install or checkout to run. Empty uses the managed install."
60
+ initialValue={viewerRoot}
61
+ placeholder="/path/to/wiki-viewer"
62
+ onChangeText={setViewerRoot}
63
+ />
64
+ <SettingsAction
65
+ label="Save"
66
+ error={settings.saveError ? String(settings.saveError) : undefined}
67
+ actionLabel={settings.saving ? "Saving…" : "Save"}
68
+ disabled={settings.saving || portError !== undefined || urlError !== undefined}
69
+ onPress={() => void settings.save({ ...settings.values, publicUrl: publicUrl.trim(), port: portValue, viewerRoot: viewerRoot.trim() }, settings.revision)}
70
+ />
71
+ </SettingsCard>
72
+ </SettingsSection>
73
+ </>
74
+ );
75
+ }
package/client/web.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { createElement, type Ref } from "react";
2
+ import { Platform } from "react-native";
3
+
4
+ // Plugins get no webview, so the Explorer panel embeds the viewer with a DOM
5
+ // iframe. That exists only on web and Electron; native clients open a browser.
6
+ export const canEmbed = Platform.OS === "web";
7
+
8
+ // This plugin typechecks without the DOM library. Declare only what this module uses.
9
+ interface WebFrame {
10
+ readonly contentWindow: { postMessage(message: unknown, targetOrigin: string): void } | null;
11
+ }
12
+ interface WebElement {
13
+ closest(selector: string): WebAnchor | null;
14
+ dispatchEvent(event: unknown): boolean;
15
+ }
16
+ interface WebAnchor {
17
+ getAttribute(name: string): string | null;
18
+ readonly style: { display: string };
19
+ }
20
+ interface WebMouseEvent {
21
+ readonly target: WebElement | null;
22
+ readonly button: number;
23
+ readonly altKey: boolean;
24
+ readonly metaKey: boolean;
25
+ readonly ctrlKey: boolean;
26
+ readonly shiftKey: boolean;
27
+ preventDefault(): void;
28
+ stopImmediatePropagation(): void;
29
+ }
30
+ interface WebGlobal {
31
+ readonly document: {
32
+ addEventListener(type: "click" | "mouseover", listener: (event: WebMouseEvent) => void, capture: boolean): void;
33
+ removeEventListener(type: "click" | "mouseover", listener: (event: WebMouseEvent) => void, capture: boolean): void;
34
+ };
35
+ readonly location: { readonly pathname: string };
36
+ readonly MouseEvent: new (type: "click", init: { bubbles: boolean; cancelable: boolean }) => unknown;
37
+ }
38
+
39
+ export function WikiFrame({ url, title, frameRef, onLoad }: { url: string; title: string; frameRef?: Ref<unknown>; onLoad?: () => void }) {
40
+ if (!canEmbed) return null;
41
+ const src = new URL(url);
42
+ src.searchParams.set("embed", "1");
43
+ src.searchParams.set("chrome", "1");
44
+ return createElement("iframe", {
45
+ ref: frameRef,
46
+ onLoad,
47
+ src: src.toString(),
48
+ title,
49
+ referrerPolicy: "no-referrer",
50
+ style: { flex: 1, width: "100%", height: "100%", border: 0 },
51
+ });
52
+ }
53
+
54
+ // Asks a loaded viewer frame to show a workspace-relative file without reloading.
55
+ export function postOpenFile(frame: unknown, frameUrl: string, path: string) {
56
+ const win = (frame as WebFrame | null)?.contentWindow;
57
+ if (!win) return;
58
+ const url = new URL(frameUrl);
59
+ win.postMessage({ type: "paseo-wiki-open", grant: url.searchParams.get("grant"), path }, url.origin);
60
+ }
61
+
62
+ /**
63
+ * Catches clicks on chat file links before Paseo opens a file tab. On web, Paseo
64
+ * renders those links as `<a style="display: contents">` around the link text.
65
+ * `open` resolves to false when the link is not a workspace file; the click is
66
+ * then replayed so Paseo handles it as usual. Alt-click always goes to Paseo.
67
+ * Hovering a link calls `prefetch`, as Paseo does, so the click is quick.
68
+ */
69
+ export function interceptChatFileLinks(handlers: {
70
+ prefetch(target: string, workspaceId: string): void;
71
+ open(target: string, workspaceId: string): Promise<boolean>;
72
+ }): () => void {
73
+ if (Platform.OS !== "web") return () => {};
74
+ const web = globalThis as unknown as WebGlobal;
75
+ const fileLinkAt = (element: WebElement | null) => {
76
+ const anchor = typeof element?.closest === "function" ? element.closest("a[href]") : null;
77
+ if (!anchor || anchor.style.display !== "contents") return null;
78
+ const href = anchor.getAttribute("href");
79
+ if (!href || (/^[a-z][a-z0-9+.-]*:/i.test(href) && !/^file:/i.test(href))) return null;
80
+ const match = /^\/h\/[^/]+\/workspace\/([^/?#]+)/.exec(web.location.pathname);
81
+ return match ? { href, workspaceId: decodeURIComponent(match[1]!) } : null;
82
+ };
83
+ let replaying = false;
84
+ const onClick = (event: WebMouseEvent) => {
85
+ if (replaying || event.button !== 0 || event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return;
86
+ const element = event.target;
87
+ const link = fileLinkAt(element);
88
+ if (!element || !link) return;
89
+ event.preventDefault();
90
+ event.stopImmediatePropagation();
91
+ const replay = () => {
92
+ replaying = true;
93
+ try {
94
+ element.dispatchEvent(new web.MouseEvent("click", { bubbles: true, cancelable: true }));
95
+ } finally {
96
+ replaying = false;
97
+ }
98
+ };
99
+ handlers.open(link.href, link.workspaceId).then(
100
+ (handled) => {
101
+ if (!handled) replay();
102
+ },
103
+ replay,
104
+ );
105
+ };
106
+ const onHover = (event: WebMouseEvent) => {
107
+ const link = fileLinkAt(event.target);
108
+ if (link) handlers.prefetch(link.href, link.workspaceId);
109
+ };
110
+ web.document.addEventListener("click", onClick, true);
111
+ web.document.addEventListener("mouseover", onHover, true);
112
+ return () => {
113
+ web.document.removeEventListener("click", onClick, true);
114
+ web.document.removeEventListener("mouseover", onHover, true);
115
+ };
116
+ }
@@ -0,0 +1,54 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { WikiPanel } from "./client/panel";
3
+ import { requestOpen } from "./client/open-requests";
4
+ import { WikiSettings } from "./client/settings";
5
+ import { interceptChatFileLinks } from "./client/web";
6
+ import { resolveWikiFile } from "./shared/wiki";
7
+
8
+ const LOOKUP_TTL_MS = 30_000;
9
+
10
+ export default function contribute(client: PluginClientContext) {
11
+ client.addWorkspacePanel({
12
+ id: "wiki",
13
+ title: "Wiki Viewer",
14
+ icon: "BookOpen",
15
+ context: "workspace",
16
+ locations: ["workspace", "explorer"],
17
+ Component: WikiPanel,
18
+ });
19
+ client.addCommandCenterItem({
20
+ id: "open-wiki",
21
+ title: "Open Wiki Viewer",
22
+ icon: "BookOpen",
23
+ keywords: ["wiki", "markdown", "files", "docs"],
24
+ context: "workspace",
25
+ onSelect({ openPanel }) {
26
+ openPanel("wiki", { location: "explorer" });
27
+ },
28
+ });
29
+ client.addSettingsScreen({ id: "viewer", title: "Wiki Viewer", icon: "BookOpen", Component: WikiSettings });
30
+ // Chat file links open in the Wiki Viewer sidebar instead of a file tab.
31
+ // Lookups started on hover are reused by the click for a short while.
32
+ const lookups = new Map<string, { at: number; result: Promise<string | null> }>();
33
+ const lookup = (target: string, workspaceId: string) => {
34
+ const key = `${workspaceId}\n${target}`;
35
+ const cached = lookups.get(key);
36
+ if (cached && Date.now() - cached.at < LOOKUP_TTL_MS) return cached.result;
37
+ const result = client.rpc(resolveWikiFile, { workspaceId, target }).then(({ path }) => path);
38
+ result.catch(() => lookups.delete(key));
39
+ if (lookups.size > 200) lookups.clear();
40
+ lookups.set(key, { at: Date.now(), result });
41
+ return result;
42
+ };
43
+ const stopIntercepting = interceptChatFileLinks({
44
+ prefetch: (target, workspaceId) => void lookup(target, workspaceId).catch(() => {}),
45
+ async open(target, workspaceId) {
46
+ const path = await lookup(target, workspaceId);
47
+ if (!path) return false;
48
+ requestOpen(workspaceId, path);
49
+ client.openPanel("wiki", { workspaceId, location: "explorer" });
50
+ return true;
51
+ },
52
+ });
53
+ return stopIntercepting;
54
+ }
@@ -0,0 +1,32 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { createWikiService, resolveWorkspaceFile, warmFileIndex } from "./server/viewer";
3
+ import { describeWikiVersion, prepareWiki, resolveWikiFile, updateWiki, wikiSettings } from "./shared/wiki";
4
+
5
+ export default function contribute(server: PluginServerContext) {
6
+ const settings = server.registerSettings(wikiSettings);
7
+ const wiki = createWikiService(async () => {
8
+ const current = await settings.read();
9
+ if (current.status !== "ready") throw new Error("Wiki Viewer settings are invalid; fix them in Settings");
10
+ return current.values;
11
+ });
12
+
13
+ server.handle(prepareWiki, async ({ workspaceId }, { paseo }) => {
14
+ // Resolve the directory on the daemon so a client cannot pick an arbitrary root.
15
+ const workspace = await paseo.workspaces.ref(workspaceId).refresh();
16
+ if (!workspace) throw new Error("workspace is unavailable");
17
+ // Chat links in this workspace resolve against a file list; start building it.
18
+ warmFileIndex(workspace.workspaceDirectory);
19
+ return wiki.prepare(workspace.workspaceDirectory);
20
+ });
21
+ server.handle(resolveWikiFile, async ({ workspaceId, target }, { paseo }) => {
22
+ // Off: report no match, so the click goes on to Paseo's file tab.
23
+ const current = await settings.read();
24
+ if (current.status === "ready" && !current.values.openChatLinks) return { path: null };
25
+ const workspace = await paseo.workspaces.ref(workspaceId).refresh();
26
+ return { path: workspace ? await resolveWorkspaceFile(workspace.workspaceDirectory, target) : null };
27
+ });
28
+ server.handle(describeWikiVersion, () => wiki.describeVersion());
29
+ server.handle(updateWiki, () => wiki.update());
30
+
31
+ return () => wiki.dispose();
32
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "paseo-wiki-viewer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Paseo plugin: browse a workspace in wiki-viewer from the Explorer sidebar, and open chat file links there",
5
+ "keywords": [
6
+ "paseo",
7
+ "paseo-plugin",
8
+ "wiki",
9
+ "markdown",
10
+ "viewer"
11
+ ],
12
+ "author": "anh-chu",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/anh-chu/paseo-wiki-viewer.git"
17
+ },
18
+ "homepage": "https://github.com/anh-chu/paseo-wiki-viewer#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/anh-chu/paseo-wiki-viewer/issues"
21
+ },
22
+ "files": [
23
+ "paseo-plugin.json",
24
+ "OVERVIEW.md",
25
+ "README.md",
26
+ "LICENSE",
27
+ "index.client.tsx",
28
+ "index.server.ts",
29
+ "client/",
30
+ "server/",
31
+ "!server/*.test.ts",
32
+ "shared/",
33
+ "assets/"
34
+ ],
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "node --import tsx --test server/*.test.ts"
38
+ },
39
+ "devDependencies": {
40
+ "@getpaseo/plugin": "0.11.2",
41
+ "@tanstack/react-query": "^5.90.11",
42
+ "@types/node": "^24.19.2",
43
+ "@types/react": "~19.2.0",
44
+ "react": "19.1.0",
45
+ "react-native": "0.81.5",
46
+ "tsx": "^4.23.15",
47
+ "typescript": "^5.9.3",
48
+ "zod": "^4.4.3"
49
+ }
50
+ }