@hienlh/ppm 0.13.15 → 0.13.17
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/CHANGELOG.md +29 -0
- package/CLAUDE.md +5 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +2135 -0
- package/bunfig.toml +2 -0
- package/dist/web/assets/{audio-preview-YOG6Biao.js → audio-preview-CEYHVcPO.js} +1 -1
- package/dist/web/assets/{chat-tab-DbdDJuLu.js → chat-tab-DgKpBVjE.js} +3 -3
- package/dist/web/assets/code-editor-Cq2IOaV8.js +8 -0
- package/dist/web/assets/{conflict-editor-DnGfriL5.js → conflict-editor-C3l0f_1F.js} +1 -1
- package/dist/web/assets/{database-viewer-AodppoTs.js → database-viewer-Bsj5g3RI.js} +1 -1
- package/dist/web/assets/{diff-viewer-DykLUwna.js → diff-viewer-LNzXRYQX.js} +1 -1
- package/dist/web/assets/{extension-webview-Bck7QuaB.js → extension-webview-ClSDQWq_.js} +1 -1
- package/dist/web/assets/file-store-DOxcU_7s.js +1 -0
- package/dist/web/assets/{glide-data-grid-BVt0mwcA.js → glide-data-grid-rA_8qdHA.js} +1 -1
- package/dist/web/assets/{image-preview-DaSmrIvY.js → image-preview-BEC5d-NV.js} +1 -1
- package/dist/web/assets/index-VDmxXycP.js +27 -0
- package/dist/web/assets/index-nC9UURj4.css +2 -0
- package/dist/web/assets/keybindings-store-CNsaPqyF.js +1 -0
- package/dist/web/assets/{markdown-renderer-B1me_hz2.js → markdown-renderer-CPVvj7aP.js} +1 -1
- package/dist/web/assets/{pdf-preview-Dci7TIL1.js → pdf-preview-Dn0-iiir.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BeM40G-J.js → port-forwarding-tab-BTScoTXw.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CGVBOwA9.js → postgres-viewer-zrm-uI0Y.js} +1 -1
- package/dist/web/assets/{settings-tab-CYS8VfNl.js → settings-tab-D3_bKOG3.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DstPySPF.js → sql-query-editor-CGIWCq6O.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-SUGEk_G1.js → sqlite-viewer-Bvd2SXu-.js} +1 -1
- package/dist/web/assets/{terminal-tab-CJvjF79J.js → terminal-tab-BwdYHHa4.js} +1 -1
- package/dist/web/assets/{video-preview-gJSKmPQr.js → video-preview-onuXf7EW.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/index.ts +0 -0
- package/src/server/routes/files.ts +15 -0
- package/src/services/file.service.ts +15 -0
- package/src/web/components/editor/editor-breadcrumb.tsx +88 -36
- package/src/web/components/explorer/file-actions.tsx +12 -129
- package/src/web/components/explorer/file-icon-map.ts +69 -0
- package/src/web/components/explorer/file-tree.tsx +177 -362
- package/src/web/components/explorer/inline-tree-input.tsx +120 -0
- package/src/web/components/explorer/tree-node-context-menu.tsx +97 -0
- package/src/web/components/explorer/tree-node.tsx +343 -0
- package/src/web/components/explorer/use-file-upload-drag.ts +84 -0
- package/src/web/components/explorer/use-tree-keyboard-nav.ts +126 -0
- package/src/web/components/layout/mobile-nav.tsx +73 -84
- package/src/web/components/layout/project-bottom-sheet.tsx +61 -82
- package/src/web/components/ui/adaptive-context-menu.tsx +245 -0
- package/src/web/components/ui/mobile-bottom-sheet.tsx +155 -0
- package/src/web/hooks/use-is-mobile.ts +28 -0
- package/src/web/hooks/use-swipe-to-dismiss.ts +51 -0
- package/src/web/stores/file-store.ts +74 -3
- package/src/web/stores/git-status-store.ts +87 -2
- package/dist/web/assets/code-editor-C4nuAsy6.js +0 -8
- package/dist/web/assets/file-store-4BpOJthN.js +0 -1
- package/dist/web/assets/index-CSK33ACc.css +0 -2
- package/dist/web/assets/index-gZKF1YKy.js +0 -27
- package/dist/web/assets/keybindings-store-DBKLTPrk.js +0 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InlineTreeInput — inline input for creating/renaming files in the tree.
|
|
3
|
+
* Renders at the correct tree depth with auto-focus.
|
|
4
|
+
* Blur or Enter confirms, Escape cancels.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
7
|
+
import { File, Folder } from "lucide-react";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
interface InlineTreeInputProps {
|
|
11
|
+
defaultValue: string;
|
|
12
|
+
placeholder: string;
|
|
13
|
+
depth: number;
|
|
14
|
+
icon: "file" | "folder";
|
|
15
|
+
onConfirm: (value: string) => Promise<void>;
|
|
16
|
+
onCancel: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function InlineTreeInput({
|
|
20
|
+
defaultValue,
|
|
21
|
+
placeholder,
|
|
22
|
+
depth,
|
|
23
|
+
icon,
|
|
24
|
+
onConfirm,
|
|
25
|
+
onCancel,
|
|
26
|
+
}: InlineTreeInputProps) {
|
|
27
|
+
const [value, setValue] = useState(defaultValue);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [submitting, setSubmitting] = useState(false);
|
|
30
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
31
|
+
const confirmedRef = useRef(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
// Auto-focus and select filename (before last dot) for rename
|
|
35
|
+
const el = inputRef.current;
|
|
36
|
+
if (!el) return;
|
|
37
|
+
requestAnimationFrame(() => {
|
|
38
|
+
el.focus();
|
|
39
|
+
if (defaultValue) {
|
|
40
|
+
const dotIdx = defaultValue.lastIndexOf(".");
|
|
41
|
+
el.setSelectionRange(0, dotIdx > 0 ? dotIdx : defaultValue.length);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}, [defaultValue]);
|
|
45
|
+
|
|
46
|
+
const doConfirm = useCallback(async () => {
|
|
47
|
+
if (confirmedRef.current || submitting) return;
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
if (!trimmed) {
|
|
50
|
+
onCancel();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
confirmedRef.current = true;
|
|
54
|
+
setSubmitting(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
try {
|
|
57
|
+
await onConfirm(trimmed);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
confirmedRef.current = false;
|
|
60
|
+
setError(err instanceof Error ? err.message : "Failed");
|
|
61
|
+
setSubmitting(false);
|
|
62
|
+
}
|
|
63
|
+
}, [value, submitting, onConfirm, onCancel]);
|
|
64
|
+
|
|
65
|
+
function handleKeyDown(e: React.KeyboardEvent) {
|
|
66
|
+
if (e.key === "Enter") {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
doConfirm();
|
|
69
|
+
} else if (e.key === "Escape") {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
onCancel();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleBlur() {
|
|
76
|
+
// Blur = confirm (VS Code style)
|
|
77
|
+
if (!confirmedRef.current) {
|
|
78
|
+
doConfirm();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const Icon = icon === "folder" ? Folder : File;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div>
|
|
86
|
+
<div
|
|
87
|
+
className={cn(
|
|
88
|
+
"flex items-center w-full gap-1.5 px-2 py-0.5",
|
|
89
|
+
"min-h-[32px] text-left",
|
|
90
|
+
)}
|
|
91
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
92
|
+
>
|
|
93
|
+
<span className="w-3.5 shrink-0" />
|
|
94
|
+
<Icon className="size-4 shrink-0 text-text-secondary" />
|
|
95
|
+
<input
|
|
96
|
+
ref={inputRef}
|
|
97
|
+
value={value}
|
|
98
|
+
onChange={(e) => { setValue(e.target.value); setError(null); }}
|
|
99
|
+
onKeyDown={handleKeyDown}
|
|
100
|
+
onBlur={handleBlur}
|
|
101
|
+
placeholder={placeholder}
|
|
102
|
+
disabled={submitting}
|
|
103
|
+
className={cn(
|
|
104
|
+
"flex-1 min-w-0 bg-input text-sm px-1.5 py-0.5 rounded border outline-none",
|
|
105
|
+
"focus:ring-1 focus:ring-primary/50",
|
|
106
|
+
error ? "border-destructive" : "border-primary",
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
{error && (
|
|
111
|
+
<p
|
|
112
|
+
className="text-[10px] text-destructive truncate"
|
|
113
|
+
style={{ paddingLeft: `${depth * 16 + 8 + 14 + 22}px` }}
|
|
114
|
+
>
|
|
115
|
+
{error}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context menu items for a tree node (file or folder).
|
|
3
|
+
*/
|
|
4
|
+
import { Download } from "lucide-react";
|
|
5
|
+
import type { FileNode, ClipboardState } from "@/stores/file-store";
|
|
6
|
+
import {
|
|
7
|
+
ContextMenuContent,
|
|
8
|
+
ContextMenuItem,
|
|
9
|
+
ContextMenuSeparator,
|
|
10
|
+
} from "@/components/ui/adaptive-context-menu";
|
|
11
|
+
|
|
12
|
+
interface TreeNodeContextMenuProps {
|
|
13
|
+
node: FileNode;
|
|
14
|
+
isDir: boolean;
|
|
15
|
+
projectName: string;
|
|
16
|
+
selectedFiles: string[];
|
|
17
|
+
compareSelection: { filePath: string; projectName: string; label: string } | null;
|
|
18
|
+
clipboard: ClipboardState | null;
|
|
19
|
+
onAction: (action: string, node: FileNode) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function TreeNodeContextMenu({
|
|
23
|
+
node,
|
|
24
|
+
isDir,
|
|
25
|
+
projectName,
|
|
26
|
+
selectedFiles,
|
|
27
|
+
compareSelection,
|
|
28
|
+
clipboard,
|
|
29
|
+
onAction,
|
|
30
|
+
}: TreeNodeContextMenuProps) {
|
|
31
|
+
return (
|
|
32
|
+
<ContextMenuContent>
|
|
33
|
+
{isDir && (
|
|
34
|
+
<>
|
|
35
|
+
<ContextMenuItem onClick={() => onAction("new-file", node)}>
|
|
36
|
+
New File
|
|
37
|
+
</ContextMenuItem>
|
|
38
|
+
<ContextMenuItem onClick={() => onAction("new-folder", node)}>
|
|
39
|
+
New Folder
|
|
40
|
+
</ContextMenuItem>
|
|
41
|
+
<ContextMenuSeparator />
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
<ContextMenuItem onClick={() => onAction("cut", node)}>
|
|
45
|
+
Cut
|
|
46
|
+
</ContextMenuItem>
|
|
47
|
+
<ContextMenuItem onClick={() => onAction("copy-file", node)}>
|
|
48
|
+
Copy
|
|
49
|
+
</ContextMenuItem>
|
|
50
|
+
{isDir && clipboard && (
|
|
51
|
+
<ContextMenuItem onClick={() => onAction("paste", node)}>
|
|
52
|
+
Paste
|
|
53
|
+
</ContextMenuItem>
|
|
54
|
+
)}
|
|
55
|
+
<ContextMenuSeparator />
|
|
56
|
+
<ContextMenuItem onClick={() => onAction("rename", node)}>
|
|
57
|
+
Rename
|
|
58
|
+
</ContextMenuItem>
|
|
59
|
+
<ContextMenuItem
|
|
60
|
+
variant="destructive"
|
|
61
|
+
onClick={() => onAction("delete", node)}
|
|
62
|
+
>
|
|
63
|
+
Delete
|
|
64
|
+
</ContextMenuItem>
|
|
65
|
+
<ContextMenuSeparator />
|
|
66
|
+
<ContextMenuItem onClick={() => onAction("copy-path", node)}>
|
|
67
|
+
Copy Path
|
|
68
|
+
</ContextMenuItem>
|
|
69
|
+
<ContextMenuSeparator />
|
|
70
|
+
<ContextMenuItem onClick={() => onAction("download", node)}>
|
|
71
|
+
<Download className="size-3.5 mr-2" />
|
|
72
|
+
Download{isDir ? " as Zip" : ""}
|
|
73
|
+
</ContextMenuItem>
|
|
74
|
+
{!isDir && (
|
|
75
|
+
<>
|
|
76
|
+
<ContextMenuSeparator />
|
|
77
|
+
<ContextMenuItem onClick={() => onAction("select-for-compare", node)}>
|
|
78
|
+
Select for Compare
|
|
79
|
+
</ContextMenuItem>
|
|
80
|
+
{compareSelection && compareSelection.projectName === projectName && compareSelection.filePath !== node.path && (
|
|
81
|
+
<ContextMenuItem onClick={() => onAction("compare-with-selected", node)}>
|
|
82
|
+
Compare with Selected ({compareSelection.label})
|
|
83
|
+
</ContextMenuItem>
|
|
84
|
+
)}
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
{!isDir && selectedFiles.length === 2 && (
|
|
88
|
+
<>
|
|
89
|
+
<ContextMenuSeparator />
|
|
90
|
+
<ContextMenuItem onClick={() => onAction("compare-selected", node)}>
|
|
91
|
+
Compare Selected
|
|
92
|
+
</ContextMenuItem>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</ContextMenuContent>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeNode component — renders a single file/folder row in the explorer tree.
|
|
3
|
+
* Handles click, drag/drop, context menu for individual tree items.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useRef, useEffect, memo } from "react";
|
|
6
|
+
import {
|
|
7
|
+
Folder,
|
|
8
|
+
FolderOpen,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
ChevronDown,
|
|
11
|
+
Loader2,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { useShallow } from "zustand/react/shallow";
|
|
14
|
+
import { useFileStore, getVisiblePaths, type FileNode, type InlineAction } from "@/stores/file-store";
|
|
15
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
16
|
+
import { useCompareStore } from "@/stores/compare-store";
|
|
17
|
+
import { useGitStatusStore, GIT_STATUS_COLORS, type GitFileStatus } from "@/stores/git-status-store";
|
|
18
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
19
|
+
import { cn } from "@/lib/utils";
|
|
20
|
+
import { toast } from "sonner";
|
|
21
|
+
import { InlineTreeInput } from "./inline-tree-input";
|
|
22
|
+
import {
|
|
23
|
+
ContextMenu,
|
|
24
|
+
ContextMenuTrigger,
|
|
25
|
+
} from "@/components/ui/adaptive-context-menu";
|
|
26
|
+
import { getFileIcon } from "./file-icon-map";
|
|
27
|
+
import { TreeNodeContextMenu } from "./tree-node-context-menu";
|
|
28
|
+
|
|
29
|
+
/** Check if drag event is from OS files (not internal PPM drag) */
|
|
30
|
+
export function isExternalFileDrag(e: React.DragEvent): boolean {
|
|
31
|
+
return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TreeNodeProps {
|
|
35
|
+
node: FileNode;
|
|
36
|
+
depth: number;
|
|
37
|
+
projectName: string;
|
|
38
|
+
onAction: (action: string, node: FileNode) => void;
|
|
39
|
+
onFileDrop: (targetDir: string, files: FileList) => void;
|
|
40
|
+
onFileOpen?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
|
|
44
|
+
const { expandedPaths, loadedPaths, inflight, toggleExpand, selectedFiles, toggleFileSelect, inlineAction, clearInlineAction, clipboard, focusedPath, setFocusedPath } = useFileStore(
|
|
45
|
+
useShallow((s) => ({
|
|
46
|
+
expandedPaths: s.expandedPaths,
|
|
47
|
+
loadedPaths: s.loadedPaths,
|
|
48
|
+
inflight: s.inflight,
|
|
49
|
+
toggleExpand: s.toggleExpand,
|
|
50
|
+
selectedFiles: s.selectedFiles,
|
|
51
|
+
toggleFileSelect: s.toggleFileSelect,
|
|
52
|
+
inlineAction: s.inlineAction,
|
|
53
|
+
clearInlineAction: s.clearInlineAction,
|
|
54
|
+
clipboard: s.clipboard,
|
|
55
|
+
focusedPath: s.focusedPath,
|
|
56
|
+
setFocusedPath: s.setFocusedPath,
|
|
57
|
+
})),
|
|
58
|
+
);
|
|
59
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
60
|
+
const compareSelection = useCompareStore((s) => s.selection);
|
|
61
|
+
const isDir = node.type === "directory";
|
|
62
|
+
// Git decoration: per-file and per-folder status
|
|
63
|
+
const gitStatus: GitFileStatus | undefined = useGitStatusStore((s) => {
|
|
64
|
+
const map = isDir ? s.folderStatuses.get(projectName) : s.fileStatuses.get(projectName);
|
|
65
|
+
return map?.get(node.path) as GitFileStatus | undefined;
|
|
66
|
+
});
|
|
67
|
+
const gitColor = gitStatus ? GIT_STATUS_COLORS[gitStatus] : undefined;
|
|
68
|
+
const isExpanded = expandedPaths.has(node.path);
|
|
69
|
+
const isSelected = selectedFiles.includes(node.path);
|
|
70
|
+
const isIgnored = node.ignored === true;
|
|
71
|
+
const isCut = clipboard?.operation === "cut" && clipboard.paths.includes(node.path);
|
|
72
|
+
const isFocused = focusedPath === node.path;
|
|
73
|
+
const isLoadingChildren = isDir && isExpanded && !loadedPaths.has(node.path) && inflight.has(node.path);
|
|
74
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
75
|
+
const dragCounter = useRef(0);
|
|
76
|
+
const rowRef = useRef<HTMLButtonElement>(null);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (isFocused && rowRef.current) {
|
|
80
|
+
rowRef.current.scrollIntoView({ block: "nearest" });
|
|
81
|
+
}
|
|
82
|
+
}, [isFocused]);
|
|
83
|
+
|
|
84
|
+
function handleClick(e: React.MouseEvent) {
|
|
85
|
+
// Ctrl+Click: toggle selection
|
|
86
|
+
if (e.metaKey || e.ctrlKey) {
|
|
87
|
+
setFocusedPath(node.path);
|
|
88
|
+
toggleFileSelect(node.path);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Shift+Click: range selection
|
|
92
|
+
if (e.shiftKey && focusedPath != null) {
|
|
93
|
+
const paths = getVisiblePaths();
|
|
94
|
+
const fromIdx = paths.indexOf(focusedPath);
|
|
95
|
+
const toIdx = paths.indexOf(node.path);
|
|
96
|
+
if (fromIdx >= 0 && toIdx >= 0) {
|
|
97
|
+
const start = Math.min(fromIdx, toIdx);
|
|
98
|
+
const end = Math.max(fromIdx, toIdx);
|
|
99
|
+
useFileStore.getState().setSelectedFiles(paths.slice(start, end + 1));
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Normal click
|
|
104
|
+
setFocusedPath(node.path);
|
|
105
|
+
useFileStore.getState().clearSelection();
|
|
106
|
+
if (isDir) {
|
|
107
|
+
toggleExpand(projectName, node.path);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
|
|
111
|
+
const isSqlite = ext === "db" || ext === "sqlite" || ext === "sqlite3";
|
|
112
|
+
openTab({
|
|
113
|
+
type: isSqlite ? "sqlite" : "editor",
|
|
114
|
+
title: node.name,
|
|
115
|
+
metadata: { filePath: node.path, projectName },
|
|
116
|
+
projectId: projectName,
|
|
117
|
+
closable: true,
|
|
118
|
+
});
|
|
119
|
+
onFileOpen?.();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleDragStart(e: React.DragEvent) {
|
|
123
|
+
const pathValue = isDir ? `${node.path}/` : node.path;
|
|
124
|
+
e.dataTransfer.setData("application/x-ppm-path", pathValue);
|
|
125
|
+
e.dataTransfer.setData("text/plain", node.name);
|
|
126
|
+
e.dataTransfer.effectAllowed = "copyMove";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Accept both external file drops and internal tree moves on directories */
|
|
130
|
+
function canAcceptDrop(e: React.DragEvent): boolean {
|
|
131
|
+
if (!isDir) return false;
|
|
132
|
+
return isExternalFileDrag(e) || e.dataTransfer.types.includes("application/x-ppm-path");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleNodeDragEnter(e: React.DragEvent) {
|
|
136
|
+
if (!canAcceptDrop(e)) return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
e.stopPropagation();
|
|
139
|
+
dragCounter.current++;
|
|
140
|
+
if (dragCounter.current === 1) setIsDragOver(true);
|
|
141
|
+
}
|
|
142
|
+
function handleNodeDragLeave(e: React.DragEvent) {
|
|
143
|
+
if (!isDir) return;
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
dragCounter.current--;
|
|
146
|
+
if (dragCounter.current === 0) setIsDragOver(false);
|
|
147
|
+
}
|
|
148
|
+
function handleNodeDragOver(e: React.DragEvent) {
|
|
149
|
+
if (!canAcceptDrop(e)) return;
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
e.stopPropagation();
|
|
152
|
+
e.dataTransfer.dropEffect = isExternalFileDrag(e) ? "copy" : "move";
|
|
153
|
+
}
|
|
154
|
+
function handleNodeDrop(e: React.DragEvent) {
|
|
155
|
+
if (!isDir) return;
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
e.stopPropagation();
|
|
158
|
+
dragCounter.current = 0;
|
|
159
|
+
setIsDragOver(false);
|
|
160
|
+
|
|
161
|
+
// External file upload
|
|
162
|
+
if (isExternalFileDrag(e)) {
|
|
163
|
+
if (e.dataTransfer.files.length > 0) onFileDrop(node.path, e.dataTransfer.files);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Internal tree move
|
|
168
|
+
const sourcePath = e.dataTransfer.getData("application/x-ppm-path").replace(/\/$/, "");
|
|
169
|
+
if (!sourcePath) return;
|
|
170
|
+
// Prevent dropping into self or descendant
|
|
171
|
+
if (sourcePath === node.path || node.path.startsWith(`${sourcePath}/`)) return;
|
|
172
|
+
// Prevent no-op (already in this folder)
|
|
173
|
+
const sourceParent = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "";
|
|
174
|
+
if (sourceParent === node.path) return;
|
|
175
|
+
|
|
176
|
+
const sourceName = sourcePath.includes("/") ? sourcePath.slice(sourcePath.lastIndexOf("/") + 1) : sourcePath;
|
|
177
|
+
const destination = node.path ? `${node.path}/${sourceName}` : sourceName;
|
|
178
|
+
api.post(`${projectUrl(projectName)}/files/move`, { source: sourcePath, destination })
|
|
179
|
+
.then(() => {
|
|
180
|
+
const store = useFileStore.getState();
|
|
181
|
+
store.invalidateIndex();
|
|
182
|
+
store.loadIndex(projectName);
|
|
183
|
+
store.invalidateFolder(projectName, sourceParent);
|
|
184
|
+
store.invalidateFolder(projectName, node.path);
|
|
185
|
+
})
|
|
186
|
+
.catch((err) => {
|
|
187
|
+
toast.error(err instanceof Error ? err.message : "Move failed");
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { icon: FileIcon, color: fileIconColor } = isDir
|
|
192
|
+
? { icon: isExpanded ? FolderOpen : Folder, color: "text-primary" }
|
|
193
|
+
: getFileIcon(node.name);
|
|
194
|
+
|
|
195
|
+
// Compact folders: collapse single-child dir chains into "a/b/c" display
|
|
196
|
+
let displayName = node.name;
|
|
197
|
+
let effectiveNode = node;
|
|
198
|
+
if (isDir && isExpanded && node.children) {
|
|
199
|
+
let current = node;
|
|
200
|
+
while (
|
|
201
|
+
current.children &&
|
|
202
|
+
current.children.length === 1 &&
|
|
203
|
+
current.children[0]!.type === "directory" &&
|
|
204
|
+
expandedPaths.has(current.children[0]!.path)
|
|
205
|
+
) {
|
|
206
|
+
current = current.children[0]!;
|
|
207
|
+
displayName += `/${current.name}`;
|
|
208
|
+
}
|
|
209
|
+
if (current !== node) effectiveNode = current;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const sortedChildren = effectiveNode.children
|
|
213
|
+
? [...effectiveNode.children].sort((a, b) => {
|
|
214
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
215
|
+
return a.name.localeCompare(b.name);
|
|
216
|
+
})
|
|
217
|
+
: [];
|
|
218
|
+
|
|
219
|
+
const isRenaming = inlineAction?.type === "rename" && inlineAction.existingNode?.path === node.path;
|
|
220
|
+
const isCreatingHere = isDir && inlineAction != null && (inlineAction.parentPath === node.path || inlineAction.parentPath === effectiveNode.path) && inlineAction.type !== "rename";
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
onDragEnter={isDir ? handleNodeDragEnter : undefined}
|
|
225
|
+
onDragLeave={isDir ? handleNodeDragLeave : undefined}
|
|
226
|
+
onDragOver={isDir ? handleNodeDragOver : undefined}
|
|
227
|
+
onDrop={isDir ? handleNodeDrop : undefined}
|
|
228
|
+
>
|
|
229
|
+
{isRenaming ? (
|
|
230
|
+
<InlineTreeInput
|
|
231
|
+
defaultValue={node.name}
|
|
232
|
+
placeholder={node.name}
|
|
233
|
+
depth={depth}
|
|
234
|
+
icon={isDir ? "folder" : "file"}
|
|
235
|
+
onConfirm={async (newName) => {
|
|
236
|
+
if (newName === node.name) { clearInlineAction(); return; }
|
|
237
|
+
const parentPath = node.path.includes("/")
|
|
238
|
+
? node.path.slice(0, node.path.lastIndexOf("/"))
|
|
239
|
+
: "";
|
|
240
|
+
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
|
241
|
+
await api.post(`${projectUrl(projectName)}/files/rename`, {
|
|
242
|
+
oldPath: node.path,
|
|
243
|
+
newPath,
|
|
244
|
+
});
|
|
245
|
+
clearInlineAction();
|
|
246
|
+
const store = useFileStore.getState();
|
|
247
|
+
store.invalidateIndex();
|
|
248
|
+
store.loadIndex(projectName);
|
|
249
|
+
store.invalidateFolder(projectName, parentPath);
|
|
250
|
+
}}
|
|
251
|
+
onCancel={clearInlineAction}
|
|
252
|
+
/>
|
|
253
|
+
) : (
|
|
254
|
+
<ContextMenu>
|
|
255
|
+
<ContextMenuTrigger asChild>
|
|
256
|
+
<button
|
|
257
|
+
ref={rowRef}
|
|
258
|
+
draggable
|
|
259
|
+
onDragStart={handleDragStart}
|
|
260
|
+
onClick={handleClick}
|
|
261
|
+
className={cn(
|
|
262
|
+
"flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
|
|
263
|
+
"min-h-[32px] hover:bg-surface-elevated transition-colors text-left",
|
|
264
|
+
"select-none",
|
|
265
|
+
(isIgnored || isCut) && "opacity-40",
|
|
266
|
+
isFocused && "bg-surface-elevated",
|
|
267
|
+
isSelected && "bg-primary/15 ring-1 ring-primary/40",
|
|
268
|
+
isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
|
|
269
|
+
)}
|
|
270
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
271
|
+
>
|
|
272
|
+
{isDir ? (
|
|
273
|
+
isLoadingChildren ? (
|
|
274
|
+
<Loader2 className="size-3.5 shrink-0 text-text-subtle animate-spin" />
|
|
275
|
+
) : isExpanded ? (
|
|
276
|
+
<ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
|
|
277
|
+
) : (
|
|
278
|
+
<ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
|
|
279
|
+
)
|
|
280
|
+
) : (
|
|
281
|
+
<span className="w-3.5 shrink-0" />
|
|
282
|
+
)}
|
|
283
|
+
<FileIcon
|
|
284
|
+
className={cn(
|
|
285
|
+
"size-4 shrink-0",
|
|
286
|
+
fileIconColor ?? "text-text-secondary",
|
|
287
|
+
)}
|
|
288
|
+
/>
|
|
289
|
+
<span className={cn("truncate", gitColor)}>{displayName}</span>
|
|
290
|
+
{gitStatus && !isDir && (
|
|
291
|
+
<span className={cn("text-[10px] ml-auto shrink-0 font-mono", gitColor)}>
|
|
292
|
+
{gitStatus}
|
|
293
|
+
</span>
|
|
294
|
+
)}
|
|
295
|
+
</button>
|
|
296
|
+
</ContextMenuTrigger>
|
|
297
|
+
<TreeNodeContextMenu
|
|
298
|
+
node={node}
|
|
299
|
+
isDir={isDir}
|
|
300
|
+
projectName={projectName}
|
|
301
|
+
selectedFiles={selectedFiles}
|
|
302
|
+
compareSelection={compareSelection}
|
|
303
|
+
clipboard={clipboard}
|
|
304
|
+
onAction={onAction}
|
|
305
|
+
/>
|
|
306
|
+
</ContextMenu>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{isDir && isExpanded && isCreatingHere && (
|
|
310
|
+
<InlineTreeInput
|
|
311
|
+
defaultValue=""
|
|
312
|
+
placeholder={inlineAction!.type === "new-file" ? "filename.ts" : "folder-name"}
|
|
313
|
+
depth={depth + 1}
|
|
314
|
+
icon={inlineAction!.type === "new-file" ? "file" : "folder"}
|
|
315
|
+
onConfirm={async (name) => {
|
|
316
|
+
const type = inlineAction!.type === "new-file" ? "file" : "directory";
|
|
317
|
+
const targetPath = effectiveNode.path || node.path;
|
|
318
|
+
const fullPath = targetPath ? `${targetPath}/${name}` : name;
|
|
319
|
+
await api.post(`${projectUrl(projectName)}/files/create`, { path: fullPath, type });
|
|
320
|
+
clearInlineAction();
|
|
321
|
+
const store = useFileStore.getState();
|
|
322
|
+
store.invalidateIndex();
|
|
323
|
+
store.loadIndex(projectName);
|
|
324
|
+
store.invalidateFolder(projectName, targetPath);
|
|
325
|
+
}}
|
|
326
|
+
onCancel={clearInlineAction}
|
|
327
|
+
/>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{isDir && isExpanded && sortedChildren.map((child) => (
|
|
331
|
+
<TreeNode
|
|
332
|
+
key={child.path}
|
|
333
|
+
node={child}
|
|
334
|
+
depth={depth + 1}
|
|
335
|
+
projectName={projectName}
|
|
336
|
+
onAction={onAction}
|
|
337
|
+
onFileDrop={onFileDrop}
|
|
338
|
+
onFileOpen={onFileOpen}
|
|
339
|
+
/>
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for file upload and root-level drag & drop in the file tree.
|
|
3
|
+
*/
|
|
4
|
+
import { useCallback, useState, useRef } from "react";
|
|
5
|
+
import { useFileStore } from "@/stores/file-store";
|
|
6
|
+
import { getAuthToken, projectUrl } from "@/lib/api-client";
|
|
7
|
+
import { isExternalFileDrag } from "./tree-node";
|
|
8
|
+
import { toast } from "sonner";
|
|
9
|
+
|
|
10
|
+
interface UseFileUploadDragOptions {
|
|
11
|
+
projectName: string | undefined;
|
|
12
|
+
setExpanded: (path: string, expanded: boolean) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useFileUploadDrag({ projectName, setExpanded }: UseFileUploadDragOptions) {
|
|
16
|
+
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
|
17
|
+
const rootDragCounter = useRef(0);
|
|
18
|
+
|
|
19
|
+
const uploadFiles = useCallback(async (targetDir: string, files: FileList) => {
|
|
20
|
+
if (!projectName) return;
|
|
21
|
+
const count = files.length;
|
|
22
|
+
const label = count === 1 ? files[0]!.name : `${count} files`;
|
|
23
|
+
const toastId = toast.loading(`Uploading ${label}…`);
|
|
24
|
+
|
|
25
|
+
const form = new FormData();
|
|
26
|
+
form.append("targetDir", targetDir);
|
|
27
|
+
for (const file of files) form.append("files", file);
|
|
28
|
+
const headers: HeadersInit = {};
|
|
29
|
+
const token = getAuthToken();
|
|
30
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${projectUrl(projectName)}/files/upload`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers,
|
|
35
|
+
body: form,
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const json = await res.json();
|
|
39
|
+
toast.error(`Upload failed: ${json.error ?? "Unknown error"}`, { id: toastId });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
toast.success(`Uploaded ${label}`, { id: toastId });
|
|
43
|
+
const store = useFileStore.getState();
|
|
44
|
+
if (store.loadedPaths.has(targetDir)) {
|
|
45
|
+
await store.invalidateFolder(projectName, targetDir);
|
|
46
|
+
}
|
|
47
|
+
if (targetDir) setExpanded(targetDir, true);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
toast.error(e instanceof Error ? e.message : "Upload failed", { id: toastId });
|
|
50
|
+
}
|
|
51
|
+
}, [projectName, setExpanded]);
|
|
52
|
+
|
|
53
|
+
function handleRootDragEnter(e: React.DragEvent) {
|
|
54
|
+
if (!isExternalFileDrag(e)) return;
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
rootDragCounter.current++;
|
|
57
|
+
if (rootDragCounter.current === 1) setIsRootDragOver(true);
|
|
58
|
+
}
|
|
59
|
+
function handleRootDragLeave() {
|
|
60
|
+
rootDragCounter.current--;
|
|
61
|
+
if (rootDragCounter.current === 0) setIsRootDragOver(false);
|
|
62
|
+
}
|
|
63
|
+
function handleRootDragOver(e: React.DragEvent) {
|
|
64
|
+
if (!isExternalFileDrag(e)) return;
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
e.dataTransfer.dropEffect = "copy";
|
|
67
|
+
}
|
|
68
|
+
function handleRootDrop(e: React.DragEvent) {
|
|
69
|
+
if (!isExternalFileDrag(e)) return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
rootDragCounter.current = 0;
|
|
72
|
+
setIsRootDragOver(false);
|
|
73
|
+
if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
uploadFiles,
|
|
78
|
+
isRootDragOver,
|
|
79
|
+
handleRootDragEnter,
|
|
80
|
+
handleRootDragLeave,
|
|
81
|
+
handleRootDragOver,
|
|
82
|
+
handleRootDrop,
|
|
83
|
+
};
|
|
84
|
+
}
|