@ziioapp/finder 0.1.0
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 +22 -0
- package/README.md +28 -0
- package/dist/async-state.d.ts +21 -0
- package/dist/async-state.js +15 -0
- package/dist/backend.d.ts +35 -0
- package/dist/backend.js +1 -0
- package/dist/components/file-item.d.ts +16 -0
- package/dist/components/file-item.js +42 -0
- package/dist/components/file-window.d.ts +4 -0
- package/dist/components/file-window.js +100 -0
- package/dist/components/folder-window.d.ts +4 -0
- package/dist/components/folder-window.js +242 -0
- package/dist/components/window-frame.d.ts +31 -0
- package/dist/components/window-frame.js +150 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +12 -0
- package/dist/finder-desktop.d.ts +6 -0
- package/dist/finder-desktop.js +50 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +45 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/path.d.ts +1 -0
- package/dist/path.js +4 -0
- package/dist/store.d.ts +57 -0
- package/dist/store.js +228 -0
- package/dist/styles/source.css +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/package.json +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ziioai
|
|
4
|
+
Copyright (c) 2023 shadcn
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @ziioapp/finder
|
|
2
|
+
|
|
3
|
+
使用 `@ziioapp/ui` 构建的可嵌入式多窗口 Finder。UI 不绑定 OPFS;应用通过 `FsBackend` 接入任意文件源。
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { FinderDesktop, createFinderStore, type FsBackend } from "@ziioapp/finder";
|
|
7
|
+
|
|
8
|
+
const finder = createFinderStore("my-files");
|
|
9
|
+
|
|
10
|
+
export function FilesPage({ backend }: { backend: FsBackend }) {
|
|
11
|
+
return <FinderDesktop backend={backend} api={finder} />;
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
应用须安装兼容版本的 React 与 `@ziioapp/ui`,并在应用根部挂载 `@ziioapp/ui` 所需的反馈展示组件。Finder 内部使用 `notify` 显示操作结果。
|
|
16
|
+
|
|
17
|
+
Tailwind 4 源码编译模式下,在应用的全局 CSS 中加入一次:
|
|
18
|
+
|
|
19
|
+
```css
|
|
20
|
+
@import "@ziioapp/ui/styles/source.css";
|
|
21
|
+
@import "@ziioapp/finder/styles/source.css";
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
应用还需提供自身的主题 CSS 与 Tailwind 扫描入口。Finder 样式使用 `@ziioapp/ui` 的语义 token,不附带独立主题。它是浏览器端交互组件;SSR/SSG 环境应只在客户端初始化窗口状态与文件后端。
|
|
25
|
+
|
|
26
|
+
`WindowFrame` 可从根入口或 `/window-frame` 子路径单独导入。`createFinderStore(scope)` 的视图模式仍沿用 `flaredrive-finder-viewmode-${scope}` 本地存储键,迁移现有应用时不会清除偏好。`DriveItem.path` 使用无前导斜杠的相对路径,具体后端负责转换。
|
|
27
|
+
|
|
28
|
+
编写独立后端时,可从 `/backend`、`/types` 导入协议类型,从 `/format` 导入无需加载 React UI 的格式工具。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type AsyncState<T, TError = Error> = {
|
|
2
|
+
status: "idle";
|
|
3
|
+
} | {
|
|
4
|
+
status: "loading";
|
|
5
|
+
previous?: T;
|
|
6
|
+
} | {
|
|
7
|
+
status: "ready";
|
|
8
|
+
data: T;
|
|
9
|
+
} | {
|
|
10
|
+
status: "empty";
|
|
11
|
+
data: T;
|
|
12
|
+
} | {
|
|
13
|
+
status: "error";
|
|
14
|
+
error: TError;
|
|
15
|
+
previous?: T;
|
|
16
|
+
} | {
|
|
17
|
+
status: "forbidden";
|
|
18
|
+
reason?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function asyncStateData<T, TError>(state: AsyncState<T, TError>): T | undefined;
|
|
21
|
+
export declare function settleAsyncState<T>(data: T, isEmpty: (data: T) => boolean): AsyncState<T>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function asyncStateData(state) {
|
|
2
|
+
switch (state.status) {
|
|
3
|
+
case "ready":
|
|
4
|
+
case "empty":
|
|
5
|
+
return state.data;
|
|
6
|
+
case "loading":
|
|
7
|
+
case "error":
|
|
8
|
+
return state.previous;
|
|
9
|
+
default:
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function settleAsyncState(data, isEmpty) {
|
|
14
|
+
return isEmpty(data) ? { status: "empty", data } : { status: "ready", data };
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DriveItem } from "./types.js";
|
|
2
|
+
export interface DriveListing {
|
|
3
|
+
items: DriveItem[];
|
|
4
|
+
fromCache: boolean;
|
|
5
|
+
cachedAt?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ReadResult {
|
|
8
|
+
blob: Blob;
|
|
9
|
+
fromCache: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface FsCapabilities {
|
|
12
|
+
upload: boolean;
|
|
13
|
+
mkdir: boolean;
|
|
14
|
+
rename: boolean;
|
|
15
|
+
remove: boolean;
|
|
16
|
+
cache: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ReadOptions {
|
|
19
|
+
forceFresh?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface FsBackend {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
capabilities: FsCapabilities;
|
|
25
|
+
initialPath(): string;
|
|
26
|
+
list(path: string, opts?: ReadOptions): Promise<DriveListing>;
|
|
27
|
+
readBlob(item: DriveItem, opts?: ReadOptions): Promise<ReadResult>;
|
|
28
|
+
upload(dirPath: string, files: File[]): Promise<void>;
|
|
29
|
+
mkdir(dirPath: string, name: string): Promise<void>;
|
|
30
|
+
rename(item: DriveItem, currentDir: string, newName: string): Promise<void>;
|
|
31
|
+
remove(item: DriveItem): Promise<void>;
|
|
32
|
+
canWriteDir(path: string): boolean;
|
|
33
|
+
canWriteItem(item: DriveItem): boolean;
|
|
34
|
+
notice(path: string): string | null;
|
|
35
|
+
}
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DriveItem } from "../types.js";
|
|
2
|
+
export declare const PREVIEW_LIMIT_BYTES: number;
|
|
3
|
+
export interface PreviewState {
|
|
4
|
+
status: "idle" | "loading" | "ready" | "unsupported" | "too_large" | "error";
|
|
5
|
+
kind: "" | "image" | "text";
|
|
6
|
+
text: string;
|
|
7
|
+
url: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const IDLE_PREVIEW: PreviewState;
|
|
11
|
+
export declare function previewKind(item: DriveItem): "" | "image" | "text";
|
|
12
|
+
export declare function sortItems(items: DriveItem[]): DriveItem[];
|
|
13
|
+
export declare function FileIcon({ item, small, }: {
|
|
14
|
+
item: DriveItem;
|
|
15
|
+
small?: boolean;
|
|
16
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@ziioapp/ui/lib/utils";
|
|
3
|
+
import { extLabel, guessContentType } from "../format.js";
|
|
4
|
+
export const PREVIEW_LIMIT_BYTES = 512 * 1024;
|
|
5
|
+
export const IDLE_PREVIEW = {
|
|
6
|
+
status: "idle",
|
|
7
|
+
kind: "",
|
|
8
|
+
text: "",
|
|
9
|
+
url: "",
|
|
10
|
+
message: "",
|
|
11
|
+
};
|
|
12
|
+
export function previewKind(item) {
|
|
13
|
+
if (!item || item.isDirectory)
|
|
14
|
+
return "";
|
|
15
|
+
const type = item.contentType || guessContentType(item.name);
|
|
16
|
+
if (type.startsWith("image/"))
|
|
17
|
+
return "image";
|
|
18
|
+
if (type.startsWith("text/") ||
|
|
19
|
+
[
|
|
20
|
+
"application/json",
|
|
21
|
+
"application/json5",
|
|
22
|
+
"application/xml",
|
|
23
|
+
"application/yaml",
|
|
24
|
+
"application/x-yaml",
|
|
25
|
+
"image/svg+xml",
|
|
26
|
+
].includes(type)) {
|
|
27
|
+
return "text";
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
export function sortItems(items) {
|
|
32
|
+
return [...items].sort((a, b) => {
|
|
33
|
+
if (a.isDirectory !== b.isDirectory)
|
|
34
|
+
return a.isDirectory ? -1 : 1;
|
|
35
|
+
return a.name.localeCompare(b.name, "zh-Hans-CN");
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function FileIcon({ item, small, }) {
|
|
39
|
+
return (_jsx("div", { className: cn("grid shrink-0 place-items-center rounded-md font-bold", small ? "size-6 text-[0.625rem]" : "size-9 text-xs", item.isDirectory
|
|
40
|
+
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
|
|
41
|
+
: "bg-muted text-muted-foreground"), children: item.isDirectory ? "DIR" : extLabel(item.name) }));
|
|
42
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Badge } from "@ziioapp/ui/components/badge";
|
|
3
|
+
import { Button } from "@ziioapp/ui/components/button";
|
|
4
|
+
import { notify } from "@ziioapp/ui/lib/feedback";
|
|
5
|
+
import { CloudDownload, Download } from "lucide-react";
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
import { useFinder } from "../context.js";
|
|
8
|
+
import { formatBytes, guessContentType } from "../format.js";
|
|
9
|
+
import { IDLE_PREVIEW, PREVIEW_LIMIT_BYTES, previewKind, } from "./file-item.js";
|
|
10
|
+
export function FileWindow({ win }) {
|
|
11
|
+
const { backend } = useFinder();
|
|
12
|
+
if (!win.fileItem) {
|
|
13
|
+
throw new Error("FileWindow requires a file item.");
|
|
14
|
+
}
|
|
15
|
+
const item = win.fileItem;
|
|
16
|
+
const cap = backend.capabilities;
|
|
17
|
+
const [preview, setPreview] = useState(IDLE_PREVIEW);
|
|
18
|
+
const [busy, setBusy] = useState(false);
|
|
19
|
+
const [forceLoad, setForceLoad] = useState(false);
|
|
20
|
+
const [fromCache, setFromCache] = useState(false);
|
|
21
|
+
const urlRef = useRef("");
|
|
22
|
+
const loadPreview = useCallback(async (forceFresh) => {
|
|
23
|
+
if (urlRef.current) {
|
|
24
|
+
URL.revokeObjectURL(urlRef.current);
|
|
25
|
+
urlRef.current = "";
|
|
26
|
+
}
|
|
27
|
+
const kind = previewKind(item);
|
|
28
|
+
if (!kind) {
|
|
29
|
+
setPreview({
|
|
30
|
+
...IDLE_PREVIEW,
|
|
31
|
+
status: "unsupported",
|
|
32
|
+
message: "此类型暂不支持内联预览。",
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!(forceLoad || forceFresh) &&
|
|
37
|
+
Number(item.size) > PREVIEW_LIMIT_BYTES) {
|
|
38
|
+
setPreview({
|
|
39
|
+
...IDLE_PREVIEW,
|
|
40
|
+
status: "too_large",
|
|
41
|
+
message: `文件超过 ${formatBytes(PREVIEW_LIMIT_BYTES)},未自动预览。`,
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setPreview({ ...IDLE_PREVIEW, status: "loading", kind });
|
|
46
|
+
try {
|
|
47
|
+
const { blob, fromCache: cached } = await backend.readBlob(item, {
|
|
48
|
+
forceFresh,
|
|
49
|
+
});
|
|
50
|
+
setFromCache(cached);
|
|
51
|
+
const type = item.contentType || guessContentType(item.name);
|
|
52
|
+
if (kind === "image" && type !== "image/svg+xml") {
|
|
53
|
+
const url = URL.createObjectURL(blob);
|
|
54
|
+
urlRef.current = url;
|
|
55
|
+
setPreview({ ...IDLE_PREVIEW, status: "ready", kind: "image", url });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const text = await blob.text();
|
|
59
|
+
setPreview({ ...IDLE_PREVIEW, status: "ready", kind: "text", text });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
setPreview({
|
|
64
|
+
...IDLE_PREVIEW,
|
|
65
|
+
status: "error",
|
|
66
|
+
message: `预览失败:${cause.message}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}, [backend, item, forceLoad]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
void loadPreview(false);
|
|
72
|
+
return () => {
|
|
73
|
+
if (urlRef.current)
|
|
74
|
+
URL.revokeObjectURL(urlRef.current);
|
|
75
|
+
};
|
|
76
|
+
}, [loadPreview]);
|
|
77
|
+
async function downloadItem() {
|
|
78
|
+
setBusy(true);
|
|
79
|
+
try {
|
|
80
|
+
const { blob } = await backend.readBlob(item);
|
|
81
|
+
const url = URL.createObjectURL(blob);
|
|
82
|
+
const link = document.createElement("a");
|
|
83
|
+
link.href = url;
|
|
84
|
+
link.download = item.name;
|
|
85
|
+
document.body.appendChild(link);
|
|
86
|
+
link.click();
|
|
87
|
+
link.remove();
|
|
88
|
+
URL.revokeObjectURL(url);
|
|
89
|
+
notify.success("下载已开始。");
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
notify.fromError(cause, "下载失败");
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const showCacheBadge = cap.cache && preview.status === "ready";
|
|
99
|
+
return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 items-center justify-center overflow-auto bg-muted/20 p-3", children: [preview.status === "loading" && (_jsx("div", { className: "text-xs text-muted-foreground", children: "\u6B63\u5728\u52A0\u8F7D\u9884\u89C8..." })), preview.kind === "image" && preview.url && (_jsx("img", { src: preview.url, alt: item.name, className: "max-h-full max-w-full rounded-md object-contain" })), preview.kind === "text" && preview.status === "ready" && (_jsx("pre", { className: "size-full overflow-auto rounded-md border border-border bg-card p-3 font-mono text-xs break-all whitespace-pre-wrap", children: preview.text })), (preview.status === "unsupported" || preview.status === "error") && (_jsx("div", { className: "text-center text-xs text-muted-foreground", children: preview.message })), preview.status === "too_large" && (_jsxs("div", { className: "flex flex-col items-center gap-2 text-center text-xs text-muted-foreground", children: [_jsx("span", { children: preview.message }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => setForceLoad(true), children: "\u4ECD\u8981\u52A0\u8F7D" })] }))] }), _jsxs("div", { className: "flex shrink-0 items-center justify-between gap-2 border-t px-3 py-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-1.5 text-[0.625rem] text-muted-foreground", children: [showCacheBadge && (_jsx(Badge, { variant: fromCache ? "secondary" : "outline", children: fromCache ? "缓存" : "实时" })), _jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "truncate", children: [formatBytes(item.size), " \u00B7", " ", item.contentType || guessContentType(item.name) || "文件"] }), _jsxs("div", { className: "truncate font-mono", children: ["/", item.path] })] })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [cap.cache && fromCache && preview.status === "ready" && (_jsxs(Button, { variant: "outline", size: "sm", disabled: busy, onClick: () => void loadPreview(true), children: [_jsx(CloudDownload, {}), "\u8BFB\u53D6\u771F\u6587\u4EF6"] })), _jsxs(Button, { variant: "outline", size: "sm", disabled: busy, onClick: () => void downloadItem(), children: [_jsx(Download, {}), "\u4E0B\u8F7D"] })] })] })] }));
|
|
100
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Alert, AlertAction, AlertDescription, AlertTitle, } from "@ziioapp/ui/components/alert";
|
|
3
|
+
import { Badge } from "@ziioapp/ui/components/badge";
|
|
4
|
+
import { Button } from "@ziioapp/ui/components/button";
|
|
5
|
+
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@ziioapp/ui/components/context-menu";
|
|
6
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle, } from "@ziioapp/ui/components/empty";
|
|
7
|
+
import { Input } from "@ziioapp/ui/components/input";
|
|
8
|
+
import { Skeleton } from "@ziioapp/ui/components/skeleton";
|
|
9
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@ziioapp/ui/components/table";
|
|
10
|
+
import { notify } from "@ziioapp/ui/lib/feedback";
|
|
11
|
+
import { cn } from "@ziioapp/ui/lib/utils";
|
|
12
|
+
import { ArrowUp, ChevronLeft, CloudDownload, FolderPlus, Home, LayoutGrid, List, RefreshCw, Upload, } from "lucide-react";
|
|
13
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
14
|
+
import { useSnapshot } from "valtio";
|
|
15
|
+
import { asyncStateData, settleAsyncState } from "../async-state.js";
|
|
16
|
+
import { useFinder } from "../context.js";
|
|
17
|
+
import { formatBytes, guessContentType } from "../format.js";
|
|
18
|
+
import { FileIcon, sortItems } from "./file-item.js";
|
|
19
|
+
function parentOf(path) {
|
|
20
|
+
const parts = path.split("/").filter(Boolean);
|
|
21
|
+
parts.pop();
|
|
22
|
+
return parts.join("/");
|
|
23
|
+
}
|
|
24
|
+
export function FolderWindow({ win }) {
|
|
25
|
+
const { api, backend } = useFinder();
|
|
26
|
+
const snap = useSnapshot(api.store);
|
|
27
|
+
const path = win.path ?? "";
|
|
28
|
+
const viewMode = win.viewMode ?? "list";
|
|
29
|
+
const refreshTick = snap.refreshSignal[path] ?? 0;
|
|
30
|
+
const [listingState, setListingState] = useState({
|
|
31
|
+
status: "idle",
|
|
32
|
+
});
|
|
33
|
+
const [selectedPath, setSelectedPath] = useState("");
|
|
34
|
+
const [operationPending, setOperationPending] = useState(false);
|
|
35
|
+
const [dragActive, setDragActive] = useState(false);
|
|
36
|
+
const [pendingDialog, setPendingDialog] = useState(null);
|
|
37
|
+
const dialogNameId = useId();
|
|
38
|
+
const fileInputRef = useRef(null);
|
|
39
|
+
const cap = backend.capabilities;
|
|
40
|
+
const inWritableArea = backend.canWriteDir(path);
|
|
41
|
+
const notice = backend.notice(path);
|
|
42
|
+
const listing = asyncStateData(listingState);
|
|
43
|
+
const items = listing?.items ?? [];
|
|
44
|
+
const fromCache = listing?.fromCache ?? false;
|
|
45
|
+
const loading = listingState.status === "loading";
|
|
46
|
+
const busy = loading || operationPending;
|
|
47
|
+
const load = useCallback(async (forceFresh) => {
|
|
48
|
+
setListingState((current) => ({
|
|
49
|
+
status: "loading",
|
|
50
|
+
previous: forceFresh ? asyncStateData(current) : undefined,
|
|
51
|
+
}));
|
|
52
|
+
try {
|
|
53
|
+
const nextListing = await backend.list(path, { forceFresh });
|
|
54
|
+
setListingState(settleAsyncState(nextListing, (result) => result.items.length === 0));
|
|
55
|
+
setSelectedPath("");
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
const error = cause instanceof Error ? cause : new Error("目录加载失败");
|
|
59
|
+
setListingState((current) => ({
|
|
60
|
+
status: "error",
|
|
61
|
+
error,
|
|
62
|
+
previous: asyncStateData(current),
|
|
63
|
+
}));
|
|
64
|
+
notify.fromError(cause, "目录加载失败");
|
|
65
|
+
}
|
|
66
|
+
}, [backend, path]);
|
|
67
|
+
// 路径变化或收到刷新信号时重载
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
void refreshTick;
|
|
70
|
+
void load(false);
|
|
71
|
+
}, [load, refreshTick]);
|
|
72
|
+
const openItem = (item) => {
|
|
73
|
+
if (item.isDirectory)
|
|
74
|
+
api.navigateWindow(win.id, item.path);
|
|
75
|
+
else
|
|
76
|
+
api.openFile(item);
|
|
77
|
+
};
|
|
78
|
+
const uploadFiles = async (fileList) => {
|
|
79
|
+
if (!inWritableArea) {
|
|
80
|
+
notify.warning("当前位置默认不可写入。");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const files = [...fileList];
|
|
84
|
+
if (files.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
setOperationPending(true);
|
|
87
|
+
try {
|
|
88
|
+
await backend.upload(path, files);
|
|
89
|
+
notify.success(`已上传 ${files.length} 个文件。`);
|
|
90
|
+
api.bumpRefresh(path);
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
notify.fromError(cause, "上传失败");
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
setOperationPending(false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const createFolder = () => {
|
|
100
|
+
if (!inWritableArea) {
|
|
101
|
+
notify.warning("当前位置默认不可写入。");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setPendingDialog({ kind: "create-folder", value: "" });
|
|
105
|
+
};
|
|
106
|
+
const submitCreateFolder = async (name) => {
|
|
107
|
+
if (!name)
|
|
108
|
+
return;
|
|
109
|
+
setOperationPending(true);
|
|
110
|
+
try {
|
|
111
|
+
await backend.mkdir(path, name);
|
|
112
|
+
notify.success("文件夹已创建。");
|
|
113
|
+
api.bumpRefresh(path);
|
|
114
|
+
setPendingDialog(null);
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
notify.fromError(cause, "创建失败");
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
setOperationPending(false);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const renameItem = (item) => {
|
|
124
|
+
if (!backend.canWriteItem(item)) {
|
|
125
|
+
notify.warning("当前位置默认不可重命名。");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
setPendingDialog({ kind: "rename", item, value: item.name });
|
|
129
|
+
};
|
|
130
|
+
const submitRenameItem = async (item, name) => {
|
|
131
|
+
if (!name || name === item.name)
|
|
132
|
+
return;
|
|
133
|
+
setOperationPending(true);
|
|
134
|
+
try {
|
|
135
|
+
await backend.rename(item, path, name);
|
|
136
|
+
notify.success("已重命名。");
|
|
137
|
+
api.bumpRefresh(path);
|
|
138
|
+
setPendingDialog(null);
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
notify.fromError(cause, "重命名失败");
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
setOperationPending(false);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const deleteItem = (item) => {
|
|
148
|
+
if (!backend.canWriteItem(item)) {
|
|
149
|
+
notify.warning("当前位置默认不可删除。");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
setPendingDialog({ kind: "delete", item });
|
|
153
|
+
};
|
|
154
|
+
const confirmDeleteItem = async (item) => {
|
|
155
|
+
setOperationPending(true);
|
|
156
|
+
try {
|
|
157
|
+
await backend.remove(item);
|
|
158
|
+
notify.success("已删除。");
|
|
159
|
+
api.bumpRefresh(path);
|
|
160
|
+
setPendingDialog(null);
|
|
161
|
+
}
|
|
162
|
+
catch (cause) {
|
|
163
|
+
notify.fromError(cause, "删除失败");
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
setOperationPending(false);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
async function downloadItem(item) {
|
|
170
|
+
setOperationPending(true);
|
|
171
|
+
try {
|
|
172
|
+
const { blob } = await backend.readBlob(item);
|
|
173
|
+
const url = URL.createObjectURL(blob);
|
|
174
|
+
const link = document.createElement("a");
|
|
175
|
+
link.href = url;
|
|
176
|
+
link.download = item.name;
|
|
177
|
+
document.body.appendChild(link);
|
|
178
|
+
link.click();
|
|
179
|
+
link.remove();
|
|
180
|
+
URL.revokeObjectURL(url);
|
|
181
|
+
notify.success("下载已开始。");
|
|
182
|
+
}
|
|
183
|
+
catch (cause) {
|
|
184
|
+
notify.fromError(cause, "下载失败");
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
setOperationPending(false);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const sorted = sortItems(items);
|
|
191
|
+
const breadcrumbs = path.split("/").filter(Boolean);
|
|
192
|
+
const selectedItem = items.find((i) => i.path === selectedPath) || null;
|
|
193
|
+
// PLACEHOLDER_JSX
|
|
194
|
+
return (_jsxs("div", { className: "relative flex h-full flex-col", children: [_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-1 border-b px-2 py-1.5", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", disabled: busy || path === "", onClick: () => api.navigateWindow(win.id, parentOf(path)), "aria-label": "\u4E0A\u7EA7", children: _jsx(ArrowUp, {}) }), _jsx(Button, { variant: "ghost", size: "icon-sm", disabled: busy, onClick: () => api.navigateWindow(win.id, backend.initialPath()), "aria-label": "\u8D77\u59CB\u76EE\u5F55", children: _jsx(Home, {}) }), _jsx(Button, { variant: "ghost", size: "icon-sm", disabled: busy, onClick: () => void load(false), "aria-label": "\u5237\u65B0", children: _jsx(RefreshCw, { className: cn(busy && "animate-spin") }) }), cap.upload && (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-1 h-4 w-px bg-border" }), _jsx(Button, { variant: "ghost", size: "icon-sm", disabled: busy || !inWritableArea, onClick: () => fileInputRef.current?.click(), "aria-label": "\u4E0A\u4F20", children: _jsx(Upload, {}) }), _jsx(Button, { variant: "ghost", size: "icon-sm", disabled: busy || !inWritableArea, onClick: createFolder, "aria-label": "\u65B0\u5EFA\u6587\u4EF6\u5939", children: _jsx(FolderPlus, {}) })] })), _jsx("div", { className: "flex-1" }), cap.cache && fromCache && (_jsxs(Button, { variant: "outline", size: "xs", disabled: busy, onClick: () => void load(true), children: [_jsx(CloudDownload, {}), "\u8BFB\u53D6\u771F\u76EE\u5F55"] })), _jsxs("div", { className: "flex overflow-hidden rounded-md border border-border", children: [_jsx(Button, { variant: viewMode === "list" ? "secondary" : "ghost", size: "icon-sm", className: "rounded-none border-0", onClick: () => api.setWindowViewMode(win.id, "list"), "aria-label": "\u5217\u8868\u89C6\u56FE", children: _jsx(List, {}) }), _jsx(Button, { variant: viewMode === "grid" ? "secondary" : "ghost", size: "icon-sm", className: "rounded-none border-0", onClick: () => api.setWindowViewMode(win.id, "grid"), "aria-label": "\u7F51\u683C\u89C6\u56FE", children: _jsx(LayoutGrid, {}) })] })] }), _jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-0.5 border-b px-2 py-1 text-xs", children: [_jsx(Button, { variant: path === "" ? "secondary" : "ghost", size: "xs", onClick: () => api.navigateWindow(win.id, ""), children: "\u6839\u76EE\u5F55" }), breadcrumbs.map((name, index) => {
|
|
195
|
+
const crumbPath = breadcrumbs.slice(0, index + 1).join("/");
|
|
196
|
+
return (_jsxs("span", { className: "flex items-center gap-0.5", children: [_jsx(ChevronLeft, { className: "size-3 rotate-180 text-muted-foreground" }), _jsx(Button, { variant: "ghost", size: "xs", className: "max-w-36 truncate", onClick: () => api.navigateWindow(win.id, crumbPath), children: name })] }, crumbPath));
|
|
197
|
+
})] }), notice && (_jsx("div", { className: "shrink-0 border-b bg-muted/30 px-2 py-1 text-[0.625rem] text-muted-foreground", children: notice })), _jsx("section", { className: cn("min-h-0 flex-1 overflow-auto p-2 transition-colors", dragActive && "bg-primary/5 ring-2 ring-inset ring-primary/40"), "aria-label": "\u6587\u4EF6\u5939\u5185\u5BB9", onDragEnter: (e) => {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
if (cap.upload)
|
|
200
|
+
setDragActive(true);
|
|
201
|
+
}, onDragOver: (e) => {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
if (cap.upload)
|
|
204
|
+
setDragActive(true);
|
|
205
|
+
}, onDragLeave: (e) => {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
setDragActive(false);
|
|
208
|
+
}, onDrop: (e) => {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
setDragActive(false);
|
|
211
|
+
if (cap.upload)
|
|
212
|
+
void uploadFiles(e.dataTransfer.files || []);
|
|
213
|
+
}, children: loading && items.length === 0 ? (_jsxs("div", { className: "flex flex-col gap-2 p-4", role: "status", "aria-label": "\u6B63\u5728\u52A0\u8F7D\u76EE\u5F55", children: [_jsx(Skeleton, { className: "h-7 w-full" }), _jsx(Skeleton, { className: "h-7 w-full" }), _jsx(Skeleton, { className: "h-7 w-3/4" })] })) : listingState.status === "error" && items.length === 0 ? (_jsxs(Alert, { variant: "destructive", children: [_jsx(AlertTitle, { children: "\u65E0\u6CD5\u52A0\u8F7D\u76EE\u5F55" }), _jsx(AlertDescription, { children: listingState.error.message }), _jsx(AlertAction, { children: _jsx(Button, { variant: "outline", size: "sm", onClick: () => void load(false), children: "\u91CD\u8BD5" }) })] })) : listingState.status === "empty" ? (_jsxs(Empty, { children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: "\u5F53\u524D\u6587\u4EF6\u5939\u4E3A\u7A7A" }), _jsx(EmptyDescription, { children: cap.upload && inWritableArea
|
|
214
|
+
? "可以拖入文件或点击上传。"
|
|
215
|
+
: "这里暂时没有内容。" })] }), cap.upload && inWritableArea ? (_jsx(EmptyContent, { children: _jsxs(Button, { variant: "outline", size: "sm", onClick: () => fileInputRef.current?.click(), children: [_jsx(Upload, { "data-icon": "inline-start" }), "\u4E0A\u4F20\u6587\u4EF6"] }) })) : null] })) : viewMode === "list" ? (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "\u540D\u79F0" }), _jsx(TableHead, { className: "text-end", children: "\u5927\u5C0F" }), _jsx(TableHead, { children: "\u4FEE\u6539\u65F6\u95F4" })] }) }), _jsx(TableBody, { children: sorted.map((item) => (_jsxs(ItemContextMenu, { item: item, canWrite: backend.canWriteItem(item), capUpload: cap.upload, onOpen: () => openItem(item), onOpenNewWindow: () => item.isDirectory
|
|
216
|
+
? api.openFolder(item.path, { inNewWindow: true })
|
|
217
|
+
: api.openFile(item), onDownload: () => void downloadItem(item), onRename: () => renameItem(item), onDelete: () => deleteItem(item), render: _jsx("tr", { className: cn("cursor-pointer border-b transition-colors hover:bg-muted/50", selectedPath === item.path && "bg-muted"), onClick: () => setSelectedPath(item.path), onDoubleClick: () => openItem(item) }), children: [_jsx(TableCell, { className: "max-w-0", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FileIcon, { item: item, small: true }), _jsx("span", { className: "truncate", title: item.name, children: item.name })] }) }), _jsx(TableCell, { className: "text-end text-muted-foreground", children: item.isDirectory ? "-" : formatBytes(item.size) }), _jsx(TableCell, { className: "text-muted-foreground", children: item.modified || "-" })] }, item.path))) })] })) : (_jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-2", children: sorted.map((item) => (_jsxs(ItemContextMenu, { item: item, canWrite: backend.canWriteItem(item), capUpload: cap.upload, onOpen: () => openItem(item), onOpenNewWindow: () => item.isDirectory
|
|
218
|
+
? api.openFolder(item.path, { inNewWindow: true })
|
|
219
|
+
: api.openFile(item), onDownload: () => void downloadItem(item), onRename: () => renameItem(item), onDelete: () => deleteItem(item), render: _jsx("button", { type: "button", onClick: () => setSelectedPath(item.path), onDoubleClick: () => openItem(item), className: cn("flex min-h-28 w-full min-w-0 flex-col items-center gap-2 overflow-hidden rounded-lg border border-transparent p-2 text-center transition-colors hover:bg-muted/50", selectedPath === item.path &&
|
|
220
|
+
"border-primary/40 bg-primary/5") }), children: [_jsx(FileIcon, { item: item }), _jsx("div", { className: "line-clamp-2 w-full text-xs break-all", title: item.name, children: item.name })] }, item.path))) })) }), _jsxs("div", { className: "flex shrink-0 items-center justify-between gap-2 border-t px-2 py-1 text-[0.625rem] text-muted-foreground", children: [_jsxs("span", { className: "flex items-center gap-1.5", children: [items.length, " \u9879", cap.cache && (_jsx(Badge, { variant: fromCache ? "secondary" : "outline", children: fromCache ? "缓存" : "实时" }))] }), selectedItem && (_jsxs("span", { className: "min-w-0 truncate", children: [selectedItem.name, !selectedItem.isDirectory &&
|
|
221
|
+
` · ${formatBytes(selectedItem.size)}`, !selectedItem.isDirectory &&
|
|
222
|
+
` · ${selectedItem.contentType || guessContentType(selectedItem.name) || "文件"}`] }))] }), _jsx("input", { ref: fileInputRef, className: "hidden", type: "file", multiple: true, onChange: (e) => {
|
|
223
|
+
void uploadFiles(e.target.files || []);
|
|
224
|
+
e.target.value = "";
|
|
225
|
+
} }), pendingDialog && (_jsx("div", { className: "absolute inset-0 z-20 flex items-center justify-center bg-background/55 p-4 backdrop-blur-sm", children: _jsx("div", { className: "w-full max-w-xs rounded-lg border bg-card p-4 text-sm shadow-xl", children: pendingDialog.kind === "delete" ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "font-medium", children: "\u5220\u9664\u9879\u76EE" }), _jsxs("div", { className: "mt-2 text-muted-foreground", children: ["\u786E\u5B9A\u5220\u9664\u300C", pendingDialog.item.name, "\u300D\uFF1F"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: () => setPendingDialog(null), children: "\u53D6\u6D88" }), _jsx(Button, { type: "button", variant: "destructive", size: "sm", disabled: busy, onClick: () => void confirmDeleteItem(pendingDialog.item), children: "\u5220\u9664" })] })] })) : (_jsxs("form", { onSubmit: (event) => {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
const name = pendingDialog.value.trim();
|
|
228
|
+
if (pendingDialog.kind === "create-folder") {
|
|
229
|
+
void submitCreateFolder(name);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
void submitRenameItem(pendingDialog.item, name);
|
|
233
|
+
}, children: [_jsx("label", { className: "block font-medium", htmlFor: dialogNameId, children: pendingDialog.kind === "create-folder"
|
|
234
|
+
? "文件夹名称"
|
|
235
|
+
: "新名称" }), _jsx(Input, { id: dialogNameId, className: "mt-2", autoFocus: true, value: pendingDialog.value, onChange: (event) => setPendingDialog({
|
|
236
|
+
...pendingDialog,
|
|
237
|
+
value: event.target.value,
|
|
238
|
+
}) }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: () => setPendingDialog(null), children: "\u53D6\u6D88" }), _jsx(Button, { type: "submit", size: "sm", disabled: busy, children: "\u786E\u5B9A" })] })] })) }) }))] }));
|
|
239
|
+
}
|
|
240
|
+
function ItemContextMenu({ item, canWrite, capUpload, onOpen, onOpenNewWindow, onDownload, onRename, onDelete, render, children, }) {
|
|
241
|
+
return (_jsxs(ContextMenu, { children: [_jsx(ContextMenuTrigger, { render: render, children: children }), _jsxs(ContextMenuContent, { children: [_jsx(ContextMenuItem, { onClick: onOpen, children: "\u6253\u5F00" }), _jsx(ContextMenuItem, { onClick: onOpenNewWindow, children: "\u5728\u65B0\u7A97\u53E3\u6253\u5F00" }), !item.isDirectory && (_jsx(ContextMenuItem, { onClick: onDownload, children: "\u4E0B\u8F7D" })), capUpload && (_jsxs(_Fragment, { children: [_jsx(ContextMenuSeparator, {}), _jsx(ContextMenuItem, { disabled: !canWrite, onClick: onRename, children: "\u91CD\u547D\u540D" }), _jsx(ContextMenuItem, { variant: "destructive", disabled: !canWrite, onClick: onDelete, children: "\u5220\u9664" })] }))] })] }));
|
|
242
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Rect } from "../store.js";
|
|
2
|
+
export interface WindowFrameModel {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
state: "normal" | "maximized" | "minimized";
|
|
10
|
+
z: number;
|
|
11
|
+
}
|
|
12
|
+
interface WindowFrameProps {
|
|
13
|
+
win: WindowFrameModel;
|
|
14
|
+
focused: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
titlebarClassName?: string;
|
|
17
|
+
contentClassName?: string;
|
|
18
|
+
minWidth?: number;
|
|
19
|
+
minHeight?: number;
|
|
20
|
+
/** 返回桌面当前像素尺寸,用于约束与最大化。 */
|
|
21
|
+
getDesktopRect: () => Rect;
|
|
22
|
+
onFocus: (id: string) => void;
|
|
23
|
+
onMove: (id: string, x: number, y: number) => void;
|
|
24
|
+
onResize: (id: string, rect: Rect) => void;
|
|
25
|
+
onClose?: (id: string) => void;
|
|
26
|
+
onMinimize?: (id: string) => void;
|
|
27
|
+
onToggleMaximize?: (id: string, desktop: Rect) => void;
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
export declare function WindowFrame({ win, focused, className, titlebarClassName, contentClassName, minWidth, minHeight, getDesktopRect, onFocus, onMove, onResize, onClose, onMinimize, onToggleMaximize, children, }: WindowFrameProps): import("react/jsx-runtime").JSX.Element;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@ziioapp/ui/lib/utils";
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
const MIN_W = 320;
|
|
5
|
+
const MIN_H = 220;
|
|
6
|
+
const TITLEBAR_H = 36;
|
|
7
|
+
export function WindowFrame({ win, focused, className, titlebarClassName, contentClassName, minWidth = MIN_W, minHeight = MIN_H, getDesktopRect, onFocus, onMove, onResize, onClose, onMinimize, onToggleMaximize, children, }) {
|
|
8
|
+
const dragState = useRef(null);
|
|
9
|
+
const resizeState = useRef(null);
|
|
10
|
+
const rafRef = useRef(null);
|
|
11
|
+
const pendingRef = useRef(null);
|
|
12
|
+
const maximized = win.state === "maximized";
|
|
13
|
+
const flush = () => {
|
|
14
|
+
rafRef.current = null;
|
|
15
|
+
const next = pendingRef.current;
|
|
16
|
+
if (!next)
|
|
17
|
+
return;
|
|
18
|
+
pendingRef.current = null;
|
|
19
|
+
if (resizeState.current) {
|
|
20
|
+
onResize(win.id, next);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
onMove(win.id, next.x, next.y);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const schedule = (rect) => {
|
|
27
|
+
pendingRef.current = rect;
|
|
28
|
+
if (rafRef.current == null) {
|
|
29
|
+
rafRef.current = requestAnimationFrame(flush);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
// ----- 拖拽移动 -----
|
|
33
|
+
const onTitlePointerDown = (e) => {
|
|
34
|
+
if (e.button !== 0)
|
|
35
|
+
return;
|
|
36
|
+
onFocus(win.id);
|
|
37
|
+
if (maximized)
|
|
38
|
+
return; // 最大化时不拖动
|
|
39
|
+
e.target.setPointerCapture(e.pointerId);
|
|
40
|
+
dragState.current = {
|
|
41
|
+
startX: e.clientX,
|
|
42
|
+
startY: e.clientY,
|
|
43
|
+
origX: win.x,
|
|
44
|
+
origY: win.y,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const onTitlePointerMove = (e) => {
|
|
48
|
+
const drag = dragState.current;
|
|
49
|
+
if (!drag)
|
|
50
|
+
return;
|
|
51
|
+
const desktop = getDesktopRect();
|
|
52
|
+
const dx = e.clientX - drag.startX;
|
|
53
|
+
const dy = e.clientY - drag.startY;
|
|
54
|
+
// 软约束:标题栏始终可抓取
|
|
55
|
+
const minX = -win.width + 80;
|
|
56
|
+
const maxX = desktop.width - 80;
|
|
57
|
+
const minY = 0;
|
|
58
|
+
const maxY = desktop.height - TITLEBAR_H;
|
|
59
|
+
const x = Math.max(minX, Math.min(drag.origX + dx, maxX));
|
|
60
|
+
const y = Math.max(minY, Math.min(drag.origY + dy, maxY));
|
|
61
|
+
schedule({ x, y, width: win.width, height: win.height });
|
|
62
|
+
};
|
|
63
|
+
const endDrag = (e) => {
|
|
64
|
+
if (dragState.current) {
|
|
65
|
+
e.target.releasePointerCapture?.(e.pointerId);
|
|
66
|
+
dragState.current = null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// ----- 八方向缩放 -----
|
|
70
|
+
const onResizePointerDown = (dir) => (e) => {
|
|
71
|
+
if (e.button !== 0)
|
|
72
|
+
return;
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
onFocus(win.id);
|
|
75
|
+
e.target.setPointerCapture(e.pointerId);
|
|
76
|
+
resizeState.current = {
|
|
77
|
+
dir,
|
|
78
|
+
startX: e.clientX,
|
|
79
|
+
startY: e.clientY,
|
|
80
|
+
orig: { x: win.x, y: win.y, width: win.width, height: win.height },
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const onResizePointerMove = (e) => {
|
|
84
|
+
const rs = resizeState.current;
|
|
85
|
+
if (!rs)
|
|
86
|
+
return;
|
|
87
|
+
const desktop = getDesktopRect();
|
|
88
|
+
const dx = e.clientX - rs.startX;
|
|
89
|
+
const dy = e.clientY - rs.startY;
|
|
90
|
+
let { x, y, width, height } = rs.orig;
|
|
91
|
+
if (rs.dir.includes("e")) {
|
|
92
|
+
width = Math.min(Math.max(minWidth, rs.orig.width + dx), desktop.width - rs.orig.x);
|
|
93
|
+
}
|
|
94
|
+
if (rs.dir.includes("s")) {
|
|
95
|
+
height = Math.min(Math.max(minHeight, rs.orig.height + dy), desktop.height - rs.orig.y);
|
|
96
|
+
}
|
|
97
|
+
if (rs.dir.includes("w")) {
|
|
98
|
+
const right = rs.orig.x + rs.orig.width;
|
|
99
|
+
const newX = Math.min(Math.max(0, rs.orig.x + dx), right - minWidth);
|
|
100
|
+
width = right - newX;
|
|
101
|
+
x = newX;
|
|
102
|
+
}
|
|
103
|
+
if (rs.dir.includes("n")) {
|
|
104
|
+
const bottom = rs.orig.y + rs.orig.height;
|
|
105
|
+
const newY = Math.min(Math.max(0, rs.orig.y + dy), bottom - minHeight);
|
|
106
|
+
height = bottom - newY;
|
|
107
|
+
y = newY;
|
|
108
|
+
}
|
|
109
|
+
schedule({ x, y, width, height });
|
|
110
|
+
};
|
|
111
|
+
const endResize = (e) => {
|
|
112
|
+
if (resizeState.current) {
|
|
113
|
+
e.target.releasePointerCapture?.(e.pointerId);
|
|
114
|
+
resizeState.current = null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const handles = [
|
|
118
|
+
{ dir: "n", className: "top-0 inset-x-2 h-1.5 cursor-ns-resize" },
|
|
119
|
+
{ dir: "s", className: "bottom-0 inset-x-2 h-1.5 cursor-ns-resize" },
|
|
120
|
+
{ dir: "e", className: "right-0 inset-y-2 w-1.5 cursor-ew-resize" },
|
|
121
|
+
{ dir: "w", className: "left-0 inset-y-2 w-1.5 cursor-ew-resize" },
|
|
122
|
+
{ dir: "ne", className: "top-0 right-0 size-2.5 cursor-nesw-resize" },
|
|
123
|
+
{ dir: "nw", className: "top-0 left-0 size-2.5 cursor-nwse-resize" },
|
|
124
|
+
{ dir: "se", className: "bottom-0 right-0 size-2.5 cursor-nwse-resize" },
|
|
125
|
+
{ dir: "sw", className: "bottom-0 left-0 size-2.5 cursor-nesw-resize" },
|
|
126
|
+
];
|
|
127
|
+
return (_jsxs("div", { className: cn("absolute isolate flex flex-col rounded-xl border bg-card shadow-lg transition-shadow", focused
|
|
128
|
+
? "border-border shadow-2xl ring-1 ring-foreground/10"
|
|
129
|
+
: "border-border/60",
|
|
130
|
+
// "bg-blue-500 border-green-500 ring-red-500",
|
|
131
|
+
"overflow-hidden", className), style: {
|
|
132
|
+
left: win.x,
|
|
133
|
+
top: win.y,
|
|
134
|
+
width: win.width,
|
|
135
|
+
height: win.height,
|
|
136
|
+
zIndex: win.z,
|
|
137
|
+
}, onPointerDown: () => onFocus(win.id), children: [_jsxs("div", { role: "toolbar", "aria-label": `${win.title} 窗口标题栏`, className: cn("flex shrink-0 items-center gap-2 rounded-t-[inherit] border-b bg-card px-3 select-none", maximized ? "cursor-default" : "cursor-grab active:cursor-grabbing", titlebarClassName), style: { height: TITLEBAR_H }, onPointerDown: onTitlePointerDown, onPointerMove: onTitlePointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onDoubleClick: () => onToggleMaximize?.(win.id, getDesktopRect()), children: [_jsxs("fieldset", { "aria-label": "\u7A97\u53E3\u63A7\u5236", className: "group/lights flex w-13 items-center gap-2 border-0 p-0", onPointerDown: (e) => e.stopPropagation(), onDoubleClick: (e) => e.stopPropagation(), children: [onClose && (_jsx(TrafficLight, { color: "close", onClick: () => onClose(win.id) })), onMinimize && (_jsx(TrafficLight, { color: "min", onClick: () => onMinimize(win.id) })), onToggleMaximize && (_jsx(TrafficLight, { color: "max", onClick: () => onToggleMaximize(win.id, getDesktopRect()) }))] }), _jsx("div", { className: "min-w-0 flex-1 truncate text-center text-xs font-medium text-foreground/80", children: win.title }), _jsx("div", { className: "w-13 shrink-0" })] }), _jsx("div", { className: cn("min-h-0 flex-1 overflow-hidden bg-background", contentClassName), children: children }), !maximized &&
|
|
138
|
+
handles.map((h) => (_jsx("div", { className: cn("absolute z-10", h.className), onPointerDown: onResizePointerDown(h.dir), onPointerMove: onResizePointerMove, onPointerUp: endResize, onPointerCancel: endResize }, h.dir)))] }));
|
|
139
|
+
}
|
|
140
|
+
function TrafficLight({ color, onClick, }) {
|
|
141
|
+
const styles = {
|
|
142
|
+
close: "bg-[#ff5f57] hover:bg-[#ff5f57]/80",
|
|
143
|
+
min: "bg-[#febc2e] hover:bg-[#febc2e]/80",
|
|
144
|
+
max: "bg-[#28c840] hover:bg-[#28c840]/80",
|
|
145
|
+
}[color];
|
|
146
|
+
return (_jsx("button", { type: "button", "aria-label": color, className: cn("size-3 rounded-full transition-colors", styles), onClick: (e) => {
|
|
147
|
+
e.stopPropagation();
|
|
148
|
+
onClick();
|
|
149
|
+
} }));
|
|
150
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FsBackend } from "./backend.js";
|
|
2
|
+
import type { FinderStoreApi } from "./store.js";
|
|
3
|
+
export interface FinderContextValue {
|
|
4
|
+
api: FinderStoreApi;
|
|
5
|
+
backend: FsBackend;
|
|
6
|
+
}
|
|
7
|
+
export declare function FinderProvider({ value, children, }: {
|
|
8
|
+
value: FinderContextValue;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export declare function useFinder(): FinderContextValue;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
const FinderContext = createContext(null);
|
|
4
|
+
export function FinderProvider({ value, children, }) {
|
|
5
|
+
return (_jsx(FinderContext.Provider, { value: value, children: children }));
|
|
6
|
+
}
|
|
7
|
+
export function useFinder() {
|
|
8
|
+
const ctx = useContext(FinderContext);
|
|
9
|
+
if (!ctx)
|
|
10
|
+
throw new Error("useFinder must be used within FinderProvider");
|
|
11
|
+
return ctx;
|
|
12
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button } from "@ziioapp/ui/components/button";
|
|
3
|
+
import { cn } from "@ziioapp/ui/lib/utils";
|
|
4
|
+
import { FolderPlus } from "lucide-react";
|
|
5
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
6
|
+
import { useSnapshot } from "valtio";
|
|
7
|
+
import { FileIcon } from "./components/file-item.js";
|
|
8
|
+
import { FileWindow } from "./components/file-window.js";
|
|
9
|
+
import { FolderWindow } from "./components/folder-window.js";
|
|
10
|
+
import { WindowFrame } from "./components/window-frame.js";
|
|
11
|
+
import { FinderProvider } from "./context.js";
|
|
12
|
+
export function FinderDesktop({ backend, api, }) {
|
|
13
|
+
const snap = useSnapshot(api.store);
|
|
14
|
+
const desktopRef = useRef(null);
|
|
15
|
+
const didInit = useRef(false);
|
|
16
|
+
const getDesktopRect = useCallback(() => {
|
|
17
|
+
const el = desktopRef.current;
|
|
18
|
+
if (!el)
|
|
19
|
+
return { x: 0, y: 0, width: 1024, height: 720 };
|
|
20
|
+
return { x: 0, y: 0, width: el.clientWidth, height: el.clientHeight };
|
|
21
|
+
}, []);
|
|
22
|
+
// 首次进入:恢复视图偏好并打开初始窗口。
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
api.restoreViewMode();
|
|
25
|
+
if (!didInit.current) {
|
|
26
|
+
didInit.current = true;
|
|
27
|
+
api.openFolder(backend.initialPath(), { desktop: getDesktopRect() });
|
|
28
|
+
}
|
|
29
|
+
}, [
|
|
30
|
+
getDesktopRect,
|
|
31
|
+
api.restoreViewMode,
|
|
32
|
+
backend.initialPath,
|
|
33
|
+
api.openFolder,
|
|
34
|
+
]);
|
|
35
|
+
const handleNewWindow = () => {
|
|
36
|
+
api.openFolder(backend.initialPath(), {
|
|
37
|
+
inNewWindow: true,
|
|
38
|
+
desktop: getDesktopRect(),
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
const minimized = snap.windows.filter((w) => w.state === "minimized");
|
|
42
|
+
return (_jsx(FinderProvider, { value: { backend, api }, children: _jsxs("div", { className: "flex h-full flex-col overflow-hidden rounded-md bg-background", children: [_jsxs("div", { className: "flex shrink-0 items-center gap-2 border-b bg-card/40 px-3 py-1.5 backdrop-blur-sm", children: [_jsx("span", { className: "text-sm font-medium", children: backend.label }), _jsx("div", { className: "flex-1" }), _jsxs(Button, { variant: "outline", size: "sm", onClick: handleNewWindow, children: [_jsx(FolderPlus, {}), "\u65B0\u5EFA\u7A97\u53E3"] })] }), _jsxs("div", { ref: desktopRef, className: "relative min-h-0 flex-1 overflow-hidden bg-[radial-gradient(var(--muted)_1px,transparent_1px)] [background-size:22px_22px]", children: [snap.windows.length === 0 ? (_jsxs("div", { className: "flex h-full flex-col items-center justify-center gap-3 text-muted-foreground", children: [_jsx("div", { className: "text-sm", children: "\u6CA1\u6709\u6253\u5F00\u7684\u7A97\u53E3" }), _jsxs(Button, { variant: "outline", size: "sm", onClick: handleNewWindow, children: [_jsx(FolderPlus, {}), "\u6253\u5F00"] })] })) : (snap.windows.map((win) => win.state === "minimized" ? null : (_jsx(WindowFrame, { win: win, focused: snap.focusedId === win.id, getDesktopRect: getDesktopRect, onFocus: api.focusWindow, onMove: api.moveWindow, onResize: api.resizeWindow, onClose: api.closeWindow, onMinimize: api.minimizeWindow, onToggleMaximize: api.toggleMaximize, children: win.kind === "folder" ? (_jsx(FolderWindow, { win: win })) : (_jsx(FileWindow, { win: win })) }, win.id)))), minimized.length > 0 && (_jsx("div", { className: "absolute inset-x-0 bottom-0 flex items-center justify-center gap-2 p-2", children: _jsx("div", { className: "flex max-w-full items-center gap-1.5 overflow-x-auto rounded-xl border bg-card/80 p-1.5 shadow-lg backdrop-blur-md", children: minimized.map((win) => (_jsxs("button", { type: "button", onClick: () => api.restoreWindow(win.id), className: cn("flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-muted"), title: win.title, children: [_jsx(FileIcon, { item: {
|
|
43
|
+
name: win.kind === "folder" ? "" : win.title,
|
|
44
|
+
path: "",
|
|
45
|
+
size: 0,
|
|
46
|
+
contentType: "",
|
|
47
|
+
modified: "",
|
|
48
|
+
isDirectory: win.kind === "folder",
|
|
49
|
+
}, small: true }), _jsx("span", { className: "max-w-28 truncate", children: win.title })] }, win.id))) }) }))] })] }) }));
|
|
50
|
+
}
|
package/dist/format.d.ts
ADDED
package/dist/format.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function formatBytes(bytes) {
|
|
2
|
+
const value = Number(bytes ?? 0);
|
|
3
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
4
|
+
return "0 B";
|
|
5
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
6
|
+
const index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
|
|
7
|
+
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
|
8
|
+
}
|
|
9
|
+
export function extLabel(name) {
|
|
10
|
+
const ext = String(name || "")
|
|
11
|
+
.split(".")
|
|
12
|
+
.pop();
|
|
13
|
+
return ext && ext !== name ? ext.slice(0, 4).toUpperCase() : "FILE";
|
|
14
|
+
}
|
|
15
|
+
export function guessContentType(name) {
|
|
16
|
+
const ext = String(name || "")
|
|
17
|
+
.split(".")
|
|
18
|
+
.pop()
|
|
19
|
+
?.toLowerCase();
|
|
20
|
+
const map = {
|
|
21
|
+
txt: "text/plain",
|
|
22
|
+
md: "text/markdown",
|
|
23
|
+
markdown: "text/markdown",
|
|
24
|
+
json: "application/json",
|
|
25
|
+
json5: "application/json5",
|
|
26
|
+
xml: "application/xml",
|
|
27
|
+
yml: "application/yaml",
|
|
28
|
+
yaml: "application/yaml",
|
|
29
|
+
csv: "text/csv",
|
|
30
|
+
log: "text/plain",
|
|
31
|
+
html: "text/html",
|
|
32
|
+
htm: "text/html",
|
|
33
|
+
css: "text/css",
|
|
34
|
+
js: "text/javascript",
|
|
35
|
+
ts: "text/typescript",
|
|
36
|
+
svg: "image/svg+xml",
|
|
37
|
+
png: "image/png",
|
|
38
|
+
jpg: "image/jpeg",
|
|
39
|
+
jpeg: "image/jpeg",
|
|
40
|
+
gif: "image/gif",
|
|
41
|
+
webp: "image/webp",
|
|
42
|
+
avif: "image/avif",
|
|
43
|
+
};
|
|
44
|
+
return (ext && map[ext]) || "";
|
|
45
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { DriveListing, FsBackend, FsCapabilities, ReadOptions, ReadResult, } from "./backend.js";
|
|
2
|
+
export { WindowFrame, type WindowFrameModel, } from "./components/window-frame.js";
|
|
3
|
+
export { FinderDesktop } from "./finder-desktop.js";
|
|
4
|
+
export { extLabel, formatBytes, guessContentType } from "./format.js";
|
|
5
|
+
export { joinPath } from "./path.js";
|
|
6
|
+
export type { FinderState, FinderStoreApi, FinderWindow, Rect, ViewMode, WinKind, WinState, } from "./store.js";
|
|
7
|
+
export { createFinderStore, pathTitle } from "./store.js";
|
|
8
|
+
export type { DriveItem } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// biome-ignore-all lint/performance/noBarrelFile: Finder feature public entry.
|
|
2
|
+
export { WindowFrame, } from "./components/window-frame.js";
|
|
3
|
+
export { FinderDesktop } from "./finder-desktop.js";
|
|
4
|
+
export { extLabel, formatBytes, guessContentType } from "./format.js";
|
|
5
|
+
export { joinPath } from "./path.js";
|
|
6
|
+
export { createFinderStore, pathTitle } from "./store.js";
|
package/dist/path.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function joinPath(parent: string, name: string): string;
|
package/dist/path.js
ADDED
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DriveItem } from "./types.js";
|
|
2
|
+
export type WinKind = "folder" | "file";
|
|
3
|
+
export type WinState = "normal" | "maximized" | "minimized";
|
|
4
|
+
export type ViewMode = "list" | "grid";
|
|
5
|
+
export interface Rect {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FinderWindow {
|
|
12
|
+
id: string;
|
|
13
|
+
kind: WinKind;
|
|
14
|
+
title: string;
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
prevRect?: Rect;
|
|
20
|
+
state: WinState;
|
|
21
|
+
z: number;
|
|
22
|
+
path?: string;
|
|
23
|
+
viewMode?: ViewMode;
|
|
24
|
+
fileItem?: DriveItem;
|
|
25
|
+
}
|
|
26
|
+
export interface FinderState {
|
|
27
|
+
windows: FinderWindow[];
|
|
28
|
+
focusedId: string | null;
|
|
29
|
+
nextZ: number;
|
|
30
|
+
spawnIndex: number;
|
|
31
|
+
defaultViewMode: ViewMode;
|
|
32
|
+
refreshSignal: Record<string, number>;
|
|
33
|
+
}
|
|
34
|
+
declare function pathTitle(path: string): string;
|
|
35
|
+
export interface FinderStoreApi {
|
|
36
|
+
store: FinderState;
|
|
37
|
+
viewModeKey: string;
|
|
38
|
+
openFolder: (path: string, options?: {
|
|
39
|
+
inNewWindow?: boolean;
|
|
40
|
+
desktop?: Rect;
|
|
41
|
+
}) => void;
|
|
42
|
+
openFile: (item: DriveItem, desktop?: Rect) => void;
|
|
43
|
+
closeWindow: (id: string) => void;
|
|
44
|
+
focusWindow: (id: string) => void;
|
|
45
|
+
minimizeWindow: (id: string) => void;
|
|
46
|
+
toggleMaximize: (id: string, desktop: Rect) => void;
|
|
47
|
+
restoreWindow: (id: string) => void;
|
|
48
|
+
moveWindow: (id: string, x: number, y: number) => void;
|
|
49
|
+
resizeWindow: (id: string, rect: Rect) => void;
|
|
50
|
+
navigateWindow: (id: string, path: string) => void;
|
|
51
|
+
setWindowViewMode: (id: string, mode: ViewMode) => void;
|
|
52
|
+
bumpRefresh: (path: string) => void;
|
|
53
|
+
resetWindows: () => void;
|
|
54
|
+
restoreViewMode: () => void;
|
|
55
|
+
}
|
|
56
|
+
export declare function createFinderStore(scope: string): FinderStoreApi;
|
|
57
|
+
export { pathTitle };
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { nanoid } from "nanoid";
|
|
2
|
+
import { proxy } from "valtio";
|
|
3
|
+
const FOLDER_W = 720;
|
|
4
|
+
const FOLDER_H = 480;
|
|
5
|
+
const FILE_W = 560;
|
|
6
|
+
const FILE_H = 520;
|
|
7
|
+
function pathTitle(path) {
|
|
8
|
+
const name = path.split("/").filter(Boolean).at(-1);
|
|
9
|
+
return name || "根目录";
|
|
10
|
+
}
|
|
11
|
+
export function createFinderStore(scope) {
|
|
12
|
+
const viewModeKey = `flaredrive-finder-viewmode-${scope}`;
|
|
13
|
+
const store = proxy({
|
|
14
|
+
windows: [],
|
|
15
|
+
focusedId: null,
|
|
16
|
+
nextZ: 1,
|
|
17
|
+
spawnIndex: 0,
|
|
18
|
+
defaultViewMode: "list",
|
|
19
|
+
refreshSignal: {},
|
|
20
|
+
});
|
|
21
|
+
const spawnPos = (width, height, desktop) => {
|
|
22
|
+
const offset = (store.spawnIndex % 6) * 28;
|
|
23
|
+
store.spawnIndex += 1;
|
|
24
|
+
const baseX = 32 + offset;
|
|
25
|
+
const baseY = 24 + offset;
|
|
26
|
+
if (desktop) {
|
|
27
|
+
const maxX = Math.max(8, desktop.width - width - 8);
|
|
28
|
+
const maxY = Math.max(8, desktop.height - height - 8);
|
|
29
|
+
return { x: Math.min(baseX, maxX), y: Math.min(baseY, maxY) };
|
|
30
|
+
}
|
|
31
|
+
return { x: baseX, y: baseY };
|
|
32
|
+
};
|
|
33
|
+
const bringToFront = (win) => {
|
|
34
|
+
win.z = store.nextZ;
|
|
35
|
+
store.nextZ += 1;
|
|
36
|
+
store.focusedId = win.id;
|
|
37
|
+
};
|
|
38
|
+
const refocusTop = () => {
|
|
39
|
+
const top = [...store.windows]
|
|
40
|
+
.filter((w) => w.state !== "minimized")
|
|
41
|
+
.sort((a, b) => b.z - a.z)[0];
|
|
42
|
+
store.focusedId = top?.id ?? null;
|
|
43
|
+
};
|
|
44
|
+
const openFolder = (path, options = {}) => {
|
|
45
|
+
const clean = path.replace(/^\/+|\/+$/g, "");
|
|
46
|
+
if (!options.inNewWindow) {
|
|
47
|
+
const focused = store.windows.find((w) => w.id === store.focusedId && w.kind === "folder");
|
|
48
|
+
if (focused) {
|
|
49
|
+
focused.path = clean;
|
|
50
|
+
focused.title = pathTitle(clean);
|
|
51
|
+
if (focused.state === "minimized")
|
|
52
|
+
focused.state = "normal";
|
|
53
|
+
bringToFront(focused);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const pos = spawnPos(FOLDER_W, FOLDER_H, options.desktop);
|
|
58
|
+
const win = {
|
|
59
|
+
id: nanoid(8),
|
|
60
|
+
kind: "folder",
|
|
61
|
+
title: pathTitle(clean),
|
|
62
|
+
x: pos.x,
|
|
63
|
+
y: pos.y,
|
|
64
|
+
width: FOLDER_W,
|
|
65
|
+
height: FOLDER_H,
|
|
66
|
+
state: "normal",
|
|
67
|
+
z: store.nextZ,
|
|
68
|
+
path: clean,
|
|
69
|
+
viewMode: store.defaultViewMode,
|
|
70
|
+
};
|
|
71
|
+
store.nextZ += 1;
|
|
72
|
+
store.focusedId = win.id;
|
|
73
|
+
store.windows.push(win);
|
|
74
|
+
};
|
|
75
|
+
const openFile = (item, desktop) => {
|
|
76
|
+
const existing = store.windows.find((w) => w.kind === "file" && w.fileItem?.path === item.path);
|
|
77
|
+
if (existing) {
|
|
78
|
+
if (existing.state === "minimized")
|
|
79
|
+
existing.state = "normal";
|
|
80
|
+
bringToFront(existing);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const pos = spawnPos(FILE_W, FILE_H, desktop);
|
|
84
|
+
const win = {
|
|
85
|
+
id: nanoid(8),
|
|
86
|
+
kind: "file",
|
|
87
|
+
title: item.name,
|
|
88
|
+
x: pos.x,
|
|
89
|
+
y: pos.y,
|
|
90
|
+
width: FILE_W,
|
|
91
|
+
height: FILE_H,
|
|
92
|
+
state: "normal",
|
|
93
|
+
z: store.nextZ,
|
|
94
|
+
fileItem: item,
|
|
95
|
+
};
|
|
96
|
+
store.nextZ += 1;
|
|
97
|
+
store.focusedId = win.id;
|
|
98
|
+
store.windows.push(win);
|
|
99
|
+
};
|
|
100
|
+
const closeWindow = (id) => {
|
|
101
|
+
const index = store.windows.findIndex((w) => w.id === id);
|
|
102
|
+
if (index === -1)
|
|
103
|
+
return;
|
|
104
|
+
store.windows.splice(index, 1);
|
|
105
|
+
if (store.focusedId === id)
|
|
106
|
+
refocusTop();
|
|
107
|
+
};
|
|
108
|
+
const focusWindow = (id) => {
|
|
109
|
+
const win = store.windows.find((w) => w.id === id);
|
|
110
|
+
if (!win || store.focusedId === id)
|
|
111
|
+
return;
|
|
112
|
+
bringToFront(win);
|
|
113
|
+
};
|
|
114
|
+
const minimizeWindow = (id) => {
|
|
115
|
+
const win = store.windows.find((w) => w.id === id);
|
|
116
|
+
if (!win)
|
|
117
|
+
return;
|
|
118
|
+
win.state = "minimized";
|
|
119
|
+
if (store.focusedId === id)
|
|
120
|
+
refocusTop();
|
|
121
|
+
};
|
|
122
|
+
const toggleMaximize = (id, desktop) => {
|
|
123
|
+
const win = store.windows.find((w) => w.id === id);
|
|
124
|
+
if (!win)
|
|
125
|
+
return;
|
|
126
|
+
if (win.state === "maximized") {
|
|
127
|
+
if (win.prevRect) {
|
|
128
|
+
win.x = win.prevRect.x;
|
|
129
|
+
win.y = win.prevRect.y;
|
|
130
|
+
win.width = win.prevRect.width;
|
|
131
|
+
win.height = win.prevRect.height;
|
|
132
|
+
}
|
|
133
|
+
win.state = "normal";
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
win.prevRect = {
|
|
137
|
+
x: win.x,
|
|
138
|
+
y: win.y,
|
|
139
|
+
width: win.width,
|
|
140
|
+
height: win.height,
|
|
141
|
+
};
|
|
142
|
+
win.x = 0;
|
|
143
|
+
win.y = 0;
|
|
144
|
+
win.width = desktop.width;
|
|
145
|
+
win.height = desktop.height;
|
|
146
|
+
win.state = "maximized";
|
|
147
|
+
}
|
|
148
|
+
bringToFront(win);
|
|
149
|
+
};
|
|
150
|
+
const restoreWindow = (id) => {
|
|
151
|
+
const win = store.windows.find((w) => w.id === id);
|
|
152
|
+
if (!win)
|
|
153
|
+
return;
|
|
154
|
+
if (win.state === "minimized")
|
|
155
|
+
win.state = "normal";
|
|
156
|
+
bringToFront(win);
|
|
157
|
+
};
|
|
158
|
+
const moveWindow = (id, x, y) => {
|
|
159
|
+
const win = store.windows.find((w) => w.id === id);
|
|
160
|
+
if (!win)
|
|
161
|
+
return;
|
|
162
|
+
win.x = x;
|
|
163
|
+
win.y = y;
|
|
164
|
+
};
|
|
165
|
+
const resizeWindow = (id, rect) => {
|
|
166
|
+
const win = store.windows.find((w) => w.id === id);
|
|
167
|
+
if (!win)
|
|
168
|
+
return;
|
|
169
|
+
win.x = rect.x;
|
|
170
|
+
win.y = rect.y;
|
|
171
|
+
win.width = rect.width;
|
|
172
|
+
win.height = rect.height;
|
|
173
|
+
};
|
|
174
|
+
const navigateWindow = (id, path) => {
|
|
175
|
+
const win = store.windows.find((w) => w.id === id);
|
|
176
|
+
if (!win || win.kind !== "folder")
|
|
177
|
+
return;
|
|
178
|
+
const clean = path.replace(/^\/+|\/+$/g, "");
|
|
179
|
+
win.path = clean;
|
|
180
|
+
win.title = pathTitle(clean);
|
|
181
|
+
};
|
|
182
|
+
const setWindowViewMode = (id, mode) => {
|
|
183
|
+
const win = store.windows.find((w) => w.id === id);
|
|
184
|
+
if (!win)
|
|
185
|
+
return;
|
|
186
|
+
win.viewMode = mode;
|
|
187
|
+
store.defaultViewMode = mode;
|
|
188
|
+
if (typeof window !== "undefined")
|
|
189
|
+
localStorage.setItem(viewModeKey, mode);
|
|
190
|
+
};
|
|
191
|
+
const bumpRefresh = (path) => {
|
|
192
|
+
const clean = path.replace(/^\/+|\/+$/g, "");
|
|
193
|
+
store.refreshSignal[clean] = (store.refreshSignal[clean] ?? 0) + 1;
|
|
194
|
+
};
|
|
195
|
+
const resetWindows = () => {
|
|
196
|
+
store.windows = [];
|
|
197
|
+
store.focusedId = null;
|
|
198
|
+
store.spawnIndex = 0;
|
|
199
|
+
store.refreshSignal = {};
|
|
200
|
+
};
|
|
201
|
+
const restoreViewMode = () => {
|
|
202
|
+
if (typeof window === "undefined")
|
|
203
|
+
return;
|
|
204
|
+
const saved = localStorage.getItem(viewModeKey);
|
|
205
|
+
if (saved === "grid" || saved === "list") {
|
|
206
|
+
store.defaultViewMode = saved;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
store,
|
|
211
|
+
viewModeKey,
|
|
212
|
+
openFolder,
|
|
213
|
+
openFile,
|
|
214
|
+
closeWindow,
|
|
215
|
+
focusWindow,
|
|
216
|
+
minimizeWindow,
|
|
217
|
+
toggleMaximize,
|
|
218
|
+
restoreWindow,
|
|
219
|
+
moveWindow,
|
|
220
|
+
resizeWindow,
|
|
221
|
+
navigateWindow,
|
|
222
|
+
setWindowViewMode,
|
|
223
|
+
bumpRefresh,
|
|
224
|
+
resetWindows,
|
|
225
|
+
restoreViewMode,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export { pathTitle };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@source "../";
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ziioapp/finder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Backend-agnostic multi-window Finder UI for ZiioApp",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./backend": {
|
|
12
|
+
"types": "./dist/backend.d.ts",
|
|
13
|
+
"import": "./dist/backend.js"
|
|
14
|
+
},
|
|
15
|
+
"./format": {
|
|
16
|
+
"types": "./dist/format.d.ts",
|
|
17
|
+
"import": "./dist/format.js"
|
|
18
|
+
},
|
|
19
|
+
"./types": {
|
|
20
|
+
"types": "./dist/types.d.ts",
|
|
21
|
+
"import": "./dist/types.js"
|
|
22
|
+
},
|
|
23
|
+
"./window-frame": {
|
|
24
|
+
"types": "./dist/components/window-frame.d.ts",
|
|
25
|
+
"import": "./dist/components/window-frame.js"
|
|
26
|
+
},
|
|
27
|
+
"./styles/source.css": "./dist/styles/source.css",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"lucide-react": "^1.16.0",
|
|
32
|
+
"nanoid": "^5.1.11",
|
|
33
|
+
"valtio": "^2.3.2"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/react": "19.2.14",
|
|
37
|
+
"@ziioapp/ui": "^0.2.1",
|
|
38
|
+
"react": "^19.2.4",
|
|
39
|
+
"typescript": "6.0.3"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@ziioapp/ui": "^0.2.1",
|
|
43
|
+
"react": "^19.2.4"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
"sideEffects": [
|
|
51
|
+
"**/*.css"
|
|
52
|
+
],
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public",
|
|
55
|
+
"registry": "https://registry.npmjs.org/"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"author": "ziioai",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/ziioai/ziioos.git",
|
|
62
|
+
"directory": "packages/finder"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://github.com/ziioai/ziioos#readme",
|
|
65
|
+
"bugs": {
|
|
66
|
+
"url": "https://github.com/ziioai/ziioos/issues"
|
|
67
|
+
},
|
|
68
|
+
"keywords": [
|
|
69
|
+
"finder",
|
|
70
|
+
"file-manager",
|
|
71
|
+
"react",
|
|
72
|
+
"ziioapp"
|
|
73
|
+
],
|
|
74
|
+
"scripts": {
|
|
75
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
76
|
+
"build": "node ../../scripts/build-package.mjs"
|
|
77
|
+
}
|
|
78
|
+
}
|