@spzxg/cli-ui 0.1.0 → 0.3.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.
@@ -0,0 +1,96 @@
1
+ // 看板的两个核心,各自是一块独立的积木,放到哪里都能用:
2
+ //
3
+ // <CliFilePreview jobId path /> 任务工作区里一个文件的预览 + 下载(Markdown / 代码 / CSV / 图片 / PDF / HTML / Word)
4
+ // <CliBrowserView jobId /> 任务浏览器的实时画面 + 接管 / 交还 / 打开 / 关闭
5
+ //
6
+ // 和别的组件一样:外面有 <CliCenterProvider> 就共用它(同一个对话只连一条流);没有就用自己的连接参数
7
+ // (baseUrl / token / line / headers)建一个。不给 jobId 时跟 Provider 的选中走。
8
+ // 它们只依赖中枢的看板接口(/api/jobs/:id/files、/browser…),不依赖 cli-chat / cli-board。
9
+ import { useState, type CSSProperties } from "react";
10
+ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
11
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
12
+ import { FileViewer, type FileAction } from "./FileViewer.js";
13
+ import { BrowserPane } from "./BrowserPane.js";
14
+
15
+ interface BlockProps extends CliStoreOptions {
16
+ /** 哪个对话(center 的 job_id)。不给就跟 Provider 的选中走。 */
17
+ jobId?: string | null;
18
+ theme?: "light" | "dark";
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ store?: CliStore;
22
+ onEvent?: (event: CliEvent) => void;
23
+ }
24
+
25
+ export interface CliFilePreviewProps extends BlockProps {
26
+ /** 工作区里的相对路径(如 "outputs/报告.md"),或节点上的绝对路径。 */
27
+ path: string;
28
+ /** 预览里点了别的本地文件(Markdown 里的链接)。不给就在本组件里切换过去。 */
29
+ onOpenPath?: (path: string) => void;
30
+ /** 给了就显示关闭按钮。 */
31
+ onClose?: () => void;
32
+ /** 头部的自定义按钮。 */
33
+ actions?: FileAction[];
34
+ /** 变了就重读(宿主知道文件更新时递增)。 */
35
+ version?: number;
36
+ /** 不要头部(宿主自己做标题和按钮)。 */
37
+ bare?: boolean;
38
+ }
39
+
40
+ export function CliFilePreview(props: CliFilePreviewProps) {
41
+ const ctx = useCliStoreContext();
42
+ return withOwnProvider(props, <FilePreviewInner {...props} inProvider={!!ctx} />, !!ctx);
43
+ }
44
+
45
+ function FilePreviewInner({ jobId: jobIdProp, path, onOpenPath, onClose, actions, version, bare, theme = "light", className, style, onEvent, inProvider }: CliFilePreviewProps & { inProvider: boolean }) {
46
+ useComponentEvents(onEvent, {}, inProvider);
47
+ const store = useCliStore();
48
+ const selected = useCliState((s) => s.selected);
49
+ const jobId = jobIdProp !== undefined ? jobIdProp : selected;
50
+ const [shown, setShown] = useShownPath(path);
51
+ const backend = store.client.board;
52
+ return (
53
+ <div className={`cli-ui cli-file-preview ${className ?? ""}`} data-theme={theme} style={style}>
54
+ {!backend ? <div className="cli-fv-note">当前后端不支持文件预览(需要任务中心的 /api/jobs/:id/files 接口)。</div>
55
+ : !jobId ? <div className="cli-fv-note">没有指定对话。</div>
56
+ : !shown ? <div className="cli-fv-note">没有指定文件。</div>
57
+ : <FileViewer key={`${jobId}:${shown}`} backend={backend} jobId={jobId} path={shown} version={version} actions={actions} onClose={onClose} bare={bare}
58
+ onOpenPath={onOpenPath ?? setShown} />}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ export interface CliBrowserViewProps extends BlockProps {
64
+ /** 只看:不能打开 / 关闭 / 接管。 */
65
+ readOnly?: boolean;
66
+ }
67
+
68
+ export function CliBrowserView(props: CliBrowserViewProps) {
69
+ const ctx = useCliStoreContext();
70
+ return withOwnProvider(props, <BrowserViewInner {...props} inProvider={!!ctx} />, !!ctx);
71
+ }
72
+
73
+ function BrowserViewInner({ jobId: jobIdProp, readOnly, theme = "light", className, style, onEvent, inProvider }: CliBrowserViewProps & { inProvider: boolean }) {
74
+ useComponentEvents(onEvent, {}, inProvider);
75
+ const store = useCliStore();
76
+ const selected = useCliState((s) => s.selected);
77
+ const jobId = jobIdProp !== undefined ? jobIdProp : selected;
78
+ const backend = store.client.board;
79
+ return (
80
+ <div className={`cli-ui cli-browser-view ${className ?? ""}`} data-theme={theme} style={style}>
81
+ {!backend ? <div className="cli-fv-note">当前后端不支持任务浏览器(需要任务中心的 /api/jobs/:id/browser 接口)。</div>
82
+ : !jobId ? <div className="cli-fv-note">没有指定对话。</div>
83
+ : <BrowserPane key={jobId} backend={backend} jobId={jobId} readOnly={readOnly} />}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /** 显示的路径:跟着 prop 走;预览里点了别的文件时在组件内切换,prop 变了再以 prop 为准。 */
89
+ function useShownPath(path: string): [string, (p: string) => void] {
90
+ const [state, setState] = useState({ prop: path, shown: path });
91
+ if (state.prop !== path) {
92
+ setState({ prop: path, shown: path });
93
+ return [path, (p) => setState({ prop: path, shown: p })];
94
+ }
95
+ return [state.shown, (p) => setState((s) => ({ ...s, shown: p }))];
96
+ }
@@ -0,0 +1,81 @@
1
+ // 看板按扩展名决定怎么预览一个文件。
2
+ export type PreviewKind = "markdown" | "code" | "text" | "csv" | "image" | "pdf" | "html" | "docx" | "binary";
3
+
4
+ const CODE_LANG: Record<string, string> = {
5
+ ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
6
+ json: "json", py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
7
+ c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp", php: "php", sh: "bash", bash: "bash", zsh: "bash",
8
+ sql: "sql", yaml: "yaml", yml: "yaml", toml: "ini", ini: "ini", xml: "xml", css: "css", scss: "scss",
9
+ vue: "xml", svelte: "xml", dockerfile: "dockerfile", makefile: "makefile", diff: "diff", patch: "diff",
10
+ };
11
+ const TEXT_EXT = new Set(["txt", "log", "env", "gitignore", "conf", "cfg", "properties", "lock", "tsv"]);
12
+ const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg", "avif"]);
13
+
14
+ export function extOf(path: string): string {
15
+ const name = path.split("/").pop() ?? "";
16
+ const lower = name.toLowerCase();
17
+ if (lower === "dockerfile" || lower === "makefile") return lower;
18
+ const dot = lower.lastIndexOf(".");
19
+ return dot > 0 ? lower.slice(dot + 1) : "";
20
+ }
21
+
22
+ export function previewKind(path: string, contentType = ""): PreviewKind {
23
+ const ext = extOf(path);
24
+ if (ext === "md" || ext === "markdown") return "markdown";
25
+ if (ext === "csv") return "csv";
26
+ if (IMAGE_EXT.has(ext)) return "image";
27
+ if (ext === "pdf") return "pdf";
28
+ if (ext === "html" || ext === "htm") return "html";
29
+ if (ext === "docx") return "docx";
30
+ if (CODE_LANG[ext]) return "code";
31
+ if (TEXT_EXT.has(ext)) return "text";
32
+ if (/^text\//.test(contentType) || /json|xml|javascript/.test(contentType)) return "text";
33
+ if (!ext) return "text"; // README、LICENSE 之类
34
+ return "binary";
35
+ }
36
+
37
+ export const codeLang = (path: string) => CODE_LANG[extOf(path)] ?? "";
38
+
39
+ export function fmtSize(bytes: number): string {
40
+ if (bytes < 1024) return `${bytes} B`;
41
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
42
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
43
+ }
44
+
45
+ /** 相对某个文件所在目录解析链接(Markdown 里的 ./img.png、../a.md)。 */
46
+ export function resolveRelative(fromFile: string, href: string): string {
47
+ if (href.startsWith("/") || href.startsWith("~")) return href;
48
+ const base = fromFile.split("/").slice(0, -1);
49
+ for (const seg of href.split("/")) {
50
+ if (!seg || seg === ".") continue;
51
+ if (seg === "..") base.pop();
52
+ else base.push(seg);
53
+ }
54
+ return base.join("/");
55
+ }
56
+
57
+ /** 一行 CSV(支持引号里的逗号和 "" 转义)。 */
58
+ export function parseCsv(text: string, maxRows = 500): { rows: string[][]; more: boolean } {
59
+ const rows: string[][] = [];
60
+ let row: string[] = [];
61
+ let cell = "";
62
+ let quoted = false;
63
+ const sep = text.includes("\t") && !text.split("\n")[0].includes(",") ? "\t" : ",";
64
+ for (let i = 0; i < text.length; i++) {
65
+ const ch = text[i];
66
+ if (quoted) {
67
+ if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++; }
68
+ else if (ch === '"') quoted = false;
69
+ else cell += ch;
70
+ } else if (ch === '"' && cell === "") quoted = true;
71
+ else if (ch === sep) { row.push(cell); cell = ""; }
72
+ else if (ch === "\n" || ch === "\r") {
73
+ if (ch === "\r" && text[i + 1] === "\n") i++;
74
+ row.push(cell); cell = "";
75
+ rows.push(row); row = [];
76
+ if (rows.length > maxRows) return { rows: rows.slice(0, maxRows), more: true };
77
+ } else cell += ch;
78
+ }
79
+ if (cell || row.length) { row.push(cell); rows.push(row); }
80
+ return { rows: rows.slice(0, maxRows), more: rows.length > maxRows };
81
+ }