@hienlh/ppm 0.13.15 → 0.13.16
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 +22 -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-bQ4k3Rdv.js} +1 -1
- package/dist/web/assets/{chat-tab-DbdDJuLu.js → chat-tab-DISlwA7-.js} +3 -3
- package/dist/web/assets/code-editor-Cni2pSOw.js +8 -0
- package/dist/web/assets/{conflict-editor-DnGfriL5.js → conflict-editor-82mk659D.js} +1 -1
- package/dist/web/assets/{database-viewer-AodppoTs.js → database-viewer-eCnvGdDi.js} +1 -1
- package/dist/web/assets/{diff-viewer-DykLUwna.js → diff-viewer-cezBVQp6.js} +1 -1
- package/dist/web/assets/{extension-webview-Bck7QuaB.js → extension-webview-B5dN_Qrm.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-yscGXxJe.js} +1 -1
- package/dist/web/assets/{image-preview-DaSmrIvY.js → image-preview-CGdBnOP0.js} +1 -1
- package/dist/web/assets/index-C_pdjLi6.js +27 -0
- package/dist/web/assets/index-nC9UURj4.css +2 -0
- package/dist/web/assets/keybindings-store-LHrHsvXn.js +1 -0
- package/dist/web/assets/{markdown-renderer-B1me_hz2.js → markdown-renderer-DF-Ga1mN.js} +1 -1
- package/dist/web/assets/{pdf-preview-Dci7TIL1.js → pdf-preview-C15gYiMf.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BeM40G-J.js → port-forwarding-tab-BcpVh4oH.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CGVBOwA9.js → postgres-viewer-DqcY70o6.js} +1 -1
- package/dist/web/assets/{settings-tab-CYS8VfNl.js → settings-tab-CMso6o_A.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DstPySPF.js → sql-query-editor-C0Lq3NYC.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-SUGEk_G1.js → sqlite-viewer-CFHqKvjt.js} +1 -1
- package/dist/web/assets/{terminal-tab-CJvjF79J.js → terminal-tab-ej7HGI3k.js} +1 -1
- package/dist/web/assets/{video-preview-gJSKmPQr.js → video-preview-BZLGMaKk.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 +77 -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 +46 -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,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdaptiveContextMenu — drop-in replacement for radix ContextMenu.
|
|
3
|
+
* Desktop: standard right-click context menu (radix).
|
|
4
|
+
* Mobile: long-press opens a bottom sheet.
|
|
5
|
+
*
|
|
6
|
+
* Usage: import from this file instead of "@/components/ui/context-menu".
|
|
7
|
+
* Same component names, same API — behavior adapts automatically.
|
|
8
|
+
*/
|
|
9
|
+
import React, { useState, useRef, useCallback, type ReactNode } from "react";
|
|
10
|
+
import * as Radix from "./context-menu";
|
|
11
|
+
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import {
|
|
14
|
+
BottomSheet,
|
|
15
|
+
BottomSheetCtx,
|
|
16
|
+
BottomSheetItem,
|
|
17
|
+
BottomSheetSeparator,
|
|
18
|
+
BottomSheetSubLabel,
|
|
19
|
+
BottomSheetSubContent,
|
|
20
|
+
} from "./mobile-bottom-sheet";
|
|
21
|
+
|
|
22
|
+
const LONG_PRESS_MS = 500;
|
|
23
|
+
|
|
24
|
+
const IsMobileCtx = React.createContext(false);
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Root */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
|
|
30
|
+
function ContextMenu({ children, ...props }: React.ComponentProps<typeof Radix.ContextMenu>) {
|
|
31
|
+
const isMobile = useIsMobile();
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
|
|
34
|
+
if (!isMobile) {
|
|
35
|
+
return <Radix.ContextMenu {...props}>{children}</Radix.ContextMenu>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<IsMobileCtx.Provider value={true}>
|
|
40
|
+
<BottomSheetCtx.Provider value={{ open, setOpen }}>
|
|
41
|
+
{children}
|
|
42
|
+
</BottomSheetCtx.Provider>
|
|
43
|
+
</IsMobileCtx.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------------ */
|
|
48
|
+
/* Trigger */
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
|
|
51
|
+
function ContextMenuTrigger({
|
|
52
|
+
children,
|
|
53
|
+
asChild,
|
|
54
|
+
...props
|
|
55
|
+
}: React.ComponentProps<typeof Radix.ContextMenuTrigger>) {
|
|
56
|
+
const isMobile = React.useContext(IsMobileCtx);
|
|
57
|
+
const { setOpen } = React.useContext(BottomSheetCtx);
|
|
58
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
59
|
+
const suppressRef = useRef(false);
|
|
60
|
+
|
|
61
|
+
if (!isMobile) {
|
|
62
|
+
return (
|
|
63
|
+
<Radix.ContextMenuTrigger asChild={asChild} {...props}>
|
|
64
|
+
{children}
|
|
65
|
+
</Radix.ContextMenuTrigger>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const handleTouchStart = useCallback(
|
|
70
|
+
(e: React.TouchEvent) => {
|
|
71
|
+
e.stopPropagation(); // prevent parent triggers from also firing
|
|
72
|
+
suppressRef.current = false;
|
|
73
|
+
timerRef.current = setTimeout(() => {
|
|
74
|
+
setOpen(true);
|
|
75
|
+
suppressRef.current = true;
|
|
76
|
+
}, LONG_PRESS_MS);
|
|
77
|
+
},
|
|
78
|
+
[setOpen],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const handleTouchMove = useCallback(() => {
|
|
82
|
+
clearTimeout(timerRef.current);
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const handleTouchEnd = useCallback(() => {
|
|
86
|
+
clearTimeout(timerRef.current);
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const handleClickCapture = useCallback((e: React.MouseEvent) => {
|
|
90
|
+
if (suppressRef.current) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
suppressRef.current = false;
|
|
94
|
+
}
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
e.stopPropagation();
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
onTouchStart={handleTouchStart}
|
|
105
|
+
onTouchMove={handleTouchMove}
|
|
106
|
+
onTouchEnd={handleTouchEnd}
|
|
107
|
+
onClickCapture={handleClickCapture}
|
|
108
|
+
onContextMenu={handleContextMenu}
|
|
109
|
+
className="contents"
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ------------------------------------------------------------------ */
|
|
117
|
+
/* Content */
|
|
118
|
+
/* ------------------------------------------------------------------ */
|
|
119
|
+
|
|
120
|
+
function ContextMenuContent({
|
|
121
|
+
children,
|
|
122
|
+
className,
|
|
123
|
+
...props
|
|
124
|
+
}: React.ComponentProps<typeof Radix.ContextMenuContent>) {
|
|
125
|
+
const isMobile = React.useContext(IsMobileCtx);
|
|
126
|
+
if (!isMobile) {
|
|
127
|
+
return (
|
|
128
|
+
<Radix.ContextMenuContent className={className} {...props}>
|
|
129
|
+
{children}
|
|
130
|
+
</Radix.ContextMenuContent>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const { open, setOpen } = React.useContext(BottomSheetCtx);
|
|
134
|
+
return (
|
|
135
|
+
<BottomSheet open={open} onClose={() => setOpen(false)} className={cn("p-2", className)}>
|
|
136
|
+
<div className="max-h-[60vh] overflow-y-auto">{children}</div>
|
|
137
|
+
</BottomSheet>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
/* Item */
|
|
143
|
+
/* ------------------------------------------------------------------ */
|
|
144
|
+
|
|
145
|
+
function ContextMenuItem({
|
|
146
|
+
children,
|
|
147
|
+
className,
|
|
148
|
+
variant,
|
|
149
|
+
onClick,
|
|
150
|
+
disabled,
|
|
151
|
+
...props
|
|
152
|
+
}: React.ComponentProps<typeof Radix.ContextMenuItem> & {
|
|
153
|
+
variant?: "default" | "destructive";
|
|
154
|
+
}) {
|
|
155
|
+
const isMobile = React.useContext(IsMobileCtx);
|
|
156
|
+
if (!isMobile) {
|
|
157
|
+
return (
|
|
158
|
+
<Radix.ContextMenuItem className={className} variant={variant} disabled={disabled} onClick={onClick} {...props}>
|
|
159
|
+
{children}
|
|
160
|
+
</Radix.ContextMenuItem>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return (
|
|
164
|
+
<BottomSheetItem
|
|
165
|
+
className={className}
|
|
166
|
+
variant={variant}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
onClick={onClick as unknown as (e: React.MouseEvent) => void}
|
|
169
|
+
>
|
|
170
|
+
{children}
|
|
171
|
+
</BottomSheetItem>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ------------------------------------------------------------------ */
|
|
176
|
+
/* Separator */
|
|
177
|
+
/* ------------------------------------------------------------------ */
|
|
178
|
+
|
|
179
|
+
function ContextMenuSeparator({
|
|
180
|
+
className,
|
|
181
|
+
...props
|
|
182
|
+
}: React.ComponentProps<typeof Radix.ContextMenuSeparator>) {
|
|
183
|
+
const isMobile = React.useContext(IsMobileCtx);
|
|
184
|
+
if (!isMobile) {
|
|
185
|
+
return <Radix.ContextMenuSeparator className={className} {...props} />;
|
|
186
|
+
}
|
|
187
|
+
return <BottomSheetSeparator className={className} />;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------------ */
|
|
191
|
+
/* Sub-menu (flattened on mobile) */
|
|
192
|
+
/* ------------------------------------------------------------------ */
|
|
193
|
+
|
|
194
|
+
function ContextMenuSub({ children, ...props }: React.ComponentProps<typeof Radix.ContextMenuSub>) {
|
|
195
|
+
const isMobile = React.useContext(IsMobileCtx);
|
|
196
|
+
if (!isMobile) return <Radix.ContextMenuSub {...props}>{children}</Radix.ContextMenuSub>;
|
|
197
|
+
return <>{children}</>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ContextMenuSubTrigger({
|
|
201
|
+
children,
|
|
202
|
+
className,
|
|
203
|
+
...props
|
|
204
|
+
}: React.ComponentProps<typeof Radix.ContextMenuSubTrigger>) {
|
|
205
|
+
const isMobile = React.useContext(IsMobileCtx);
|
|
206
|
+
if (!isMobile) {
|
|
207
|
+
return (
|
|
208
|
+
<Radix.ContextMenuSubTrigger className={className} {...props}>
|
|
209
|
+
{children}
|
|
210
|
+
</Radix.ContextMenuSubTrigger>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return <BottomSheetSubLabel className={className}>{children}</BottomSheetSubLabel>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function ContextMenuSubContent({
|
|
217
|
+
children,
|
|
218
|
+
className,
|
|
219
|
+
...props
|
|
220
|
+
}: React.ComponentProps<typeof Radix.ContextMenuSubContent>) {
|
|
221
|
+
const isMobile = React.useContext(IsMobileCtx);
|
|
222
|
+
if (!isMobile) {
|
|
223
|
+
return (
|
|
224
|
+
<Radix.ContextMenuSubContent className={className} {...props}>
|
|
225
|
+
{children}
|
|
226
|
+
</Radix.ContextMenuSubContent>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return <BottomSheetSubContent className={className}>{children}</BottomSheetSubContent>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
/* Exports */
|
|
234
|
+
/* ------------------------------------------------------------------ */
|
|
235
|
+
|
|
236
|
+
export {
|
|
237
|
+
ContextMenu,
|
|
238
|
+
ContextMenuTrigger,
|
|
239
|
+
ContextMenuContent,
|
|
240
|
+
ContextMenuItem,
|
|
241
|
+
ContextMenuSeparator,
|
|
242
|
+
ContextMenuSub,
|
|
243
|
+
ContextMenuSubTrigger,
|
|
244
|
+
ContextMenuSubContent,
|
|
245
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable mobile bottom sheet component.
|
|
3
|
+
* Shell: portal + backdrop + slide-up panel + drag handle + swipe-to-dismiss.
|
|
4
|
+
* Content is fully consumer-controlled.
|
|
5
|
+
*
|
|
6
|
+
* Also exports context-menu-specific sub-components (BottomSheetItem, etc.)
|
|
7
|
+
* used by adaptive-context-menu.tsx.
|
|
8
|
+
*/
|
|
9
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
import { useSwipeToDismiss } from "@/hooks/use-swipe-to-dismiss";
|
|
13
|
+
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
/* Core BottomSheet — reusable everywhere */
|
|
16
|
+
/* ------------------------------------------------------------------ */
|
|
17
|
+
|
|
18
|
+
interface BottomSheetProps {
|
|
19
|
+
open: boolean;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
/** Override z-index for stacked sheets (default: z-50) */
|
|
24
|
+
zIndex?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* General-purpose bottom sheet with swipe-to-dismiss.
|
|
29
|
+
* Renders portal + backdrop + rounded panel + drag handle.
|
|
30
|
+
* Put any content inside — headers, lists, forms, etc.
|
|
31
|
+
*/
|
|
32
|
+
export function BottomSheet({ open, onClose, children, className, zIndex = 50 }: BottomSheetProps) {
|
|
33
|
+
const { dragY, swipeHandlers, dragStyle, backdropOpacity, isDragging } =
|
|
34
|
+
useSwipeToDismiss(onClose);
|
|
35
|
+
|
|
36
|
+
if (!open) return null;
|
|
37
|
+
|
|
38
|
+
return createPortal(
|
|
39
|
+
<div className="fixed inset-0" style={{ zIndex }} onClick={onClose}>
|
|
40
|
+
{/* Backdrop */}
|
|
41
|
+
<div
|
|
42
|
+
className="absolute inset-0 bg-black/40 animate-in fade-in-0 duration-200"
|
|
43
|
+
style={isDragging ? { opacity: backdropOpacity } : undefined}
|
|
44
|
+
/>
|
|
45
|
+
{/* Panel */}
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"absolute bottom-0 left-0 right-0 rounded-t-2xl bg-popover border-t border-border",
|
|
49
|
+
"pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
50
|
+
!isDragging && "animate-in slide-in-from-bottom duration-200",
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
style={dragStyle}
|
|
54
|
+
onClick={(e) => e.stopPropagation()}
|
|
55
|
+
{...swipeHandlers}
|
|
56
|
+
>
|
|
57
|
+
{/* Drag handle */}
|
|
58
|
+
<div className="flex justify-center pt-3 pb-1">
|
|
59
|
+
<div className="w-10 h-1 rounded-full bg-border" />
|
|
60
|
+
</div>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
</div>,
|
|
64
|
+
document.body,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ------------------------------------------------------------------ */
|
|
69
|
+
/* Context-menu-specific helpers (used by adaptive-context-menu) */
|
|
70
|
+
/* ------------------------------------------------------------------ */
|
|
71
|
+
|
|
72
|
+
/** Context for adaptive-context-menu to pass open/close state */
|
|
73
|
+
export interface BottomSheetState {
|
|
74
|
+
open: boolean;
|
|
75
|
+
setOpen: (v: boolean) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const BottomSheetCtx = createContext<BottomSheetState>({
|
|
79
|
+
open: false,
|
|
80
|
+
setOpen: () => {},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Menu item styled for touch (44px+ height), auto-closes sheet on click */
|
|
84
|
+
export function BottomSheetItem({
|
|
85
|
+
children,
|
|
86
|
+
onClick,
|
|
87
|
+
variant,
|
|
88
|
+
className,
|
|
89
|
+
disabled,
|
|
90
|
+
}: {
|
|
91
|
+
children: ReactNode;
|
|
92
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
93
|
+
variant?: "default" | "destructive";
|
|
94
|
+
className?: string;
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
}) {
|
|
97
|
+
const { setOpen } = useContext(BottomSheetCtx);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<button
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
className={cn(
|
|
103
|
+
"flex w-full items-center gap-2 rounded-lg px-3 py-3 text-sm text-left",
|
|
104
|
+
"active:bg-accent transition-colors select-none",
|
|
105
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
106
|
+
variant === "destructive" && "text-destructive",
|
|
107
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
108
|
+
className,
|
|
109
|
+
)}
|
|
110
|
+
onClick={(e) => {
|
|
111
|
+
onClick?.(e);
|
|
112
|
+
setOpen(false);
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
{children}
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Separator line */
|
|
121
|
+
export function BottomSheetSeparator({ className }: { className?: string }) {
|
|
122
|
+
return <div className={cn("-mx-1 my-1 h-px bg-border", className)} />;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Sub-menu label (flattened on mobile) */
|
|
126
|
+
export function BottomSheetSubLabel({
|
|
127
|
+
children,
|
|
128
|
+
className,
|
|
129
|
+
}: {
|
|
130
|
+
children: ReactNode;
|
|
131
|
+
className?: string;
|
|
132
|
+
}) {
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
className={cn(
|
|
136
|
+
"flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground select-none",
|
|
137
|
+
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
138
|
+
className,
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Sub-menu content wrapper (indented on mobile) */
|
|
147
|
+
export function BottomSheetSubContent({
|
|
148
|
+
children,
|
|
149
|
+
className,
|
|
150
|
+
}: {
|
|
151
|
+
children: ReactNode;
|
|
152
|
+
className?: string;
|
|
153
|
+
}) {
|
|
154
|
+
return <div className={cn("pl-2", className)}>{children}</div>;
|
|
155
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized mobile detection hook.
|
|
3
|
+
* Returns true when viewport width < 768px (Tailwind md breakpoint).
|
|
4
|
+
* Reactive — updates on window resize.
|
|
5
|
+
*/
|
|
6
|
+
import { useSyncExternalStore } from "react";
|
|
7
|
+
|
|
8
|
+
function subscribe(cb: () => void) {
|
|
9
|
+
window.addEventListener("resize", cb);
|
|
10
|
+
return () => window.removeEventListener("resize", cb);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getSnapshot() {
|
|
14
|
+
return typeof window !== "undefined" && window.innerWidth < 768;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getServerSnapshot() {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useIsMobile() {
|
|
22
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Non-hook check for use outside React components */
|
|
26
|
+
export function isMobileDevice() {
|
|
27
|
+
return typeof window !== "undefined" && window.innerWidth < 768;
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for swipe-down-to-dismiss gesture on mobile bottom sheets.
|
|
3
|
+
* Returns touch handlers, drag offset, and style helpers.
|
|
4
|
+
*/
|
|
5
|
+
import { useRef, useCallback, useState } from "react";
|
|
6
|
+
|
|
7
|
+
const DISMISS_THRESHOLD = 80;
|
|
8
|
+
|
|
9
|
+
export function useSwipeToDismiss(onDismiss: () => void) {
|
|
10
|
+
const [dragY, setDragY] = useState(0);
|
|
11
|
+
const startYRef = useRef(0);
|
|
12
|
+
const draggingRef = useRef(false);
|
|
13
|
+
|
|
14
|
+
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
15
|
+
startYRef.current = e.touches[0]!.clientY;
|
|
16
|
+
draggingRef.current = true;
|
|
17
|
+
setDragY(0);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
21
|
+
if (!draggingRef.current) return;
|
|
22
|
+
const dy = e.touches[0]!.clientY - startYRef.current;
|
|
23
|
+
setDragY(Math.max(0, dy));
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const handleTouchEnd = useCallback(() => {
|
|
27
|
+
if (!draggingRef.current) return;
|
|
28
|
+
draggingRef.current = false;
|
|
29
|
+
if (dragY >= DISMISS_THRESHOLD) {
|
|
30
|
+
onDismiss();
|
|
31
|
+
}
|
|
32
|
+
setDragY(0);
|
|
33
|
+
}, [dragY, onDismiss]);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
dragY,
|
|
37
|
+
swipeHandlers: {
|
|
38
|
+
onTouchStart: handleTouchStart,
|
|
39
|
+
onTouchMove: handleTouchMove,
|
|
40
|
+
onTouchEnd: handleTouchEnd,
|
|
41
|
+
},
|
|
42
|
+
dragStyle: dragY > 0 ? { transform: `translateY(${dragY}px)` } as const : undefined,
|
|
43
|
+
backdropOpacity: dragY > 0 ? Math.max(0, 1 - dragY / 300) : 1,
|
|
44
|
+
isDragging: dragY > 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -16,6 +16,21 @@ export interface FileNode {
|
|
|
16
16
|
ignored?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** State for inline create/rename in the file tree */
|
|
20
|
+
export interface InlineAction {
|
|
21
|
+
type: "new-file" | "new-folder" | "rename";
|
|
22
|
+
/** Parent directory path (for new-file/new-folder) or parent of the renamed file */
|
|
23
|
+
parentPath: string;
|
|
24
|
+
/** Existing node being renamed (only for type=rename) */
|
|
25
|
+
existingNode?: FileNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Clipboard state for cut/copy/paste */
|
|
29
|
+
export interface ClipboardState {
|
|
30
|
+
paths: string[];
|
|
31
|
+
operation: "cut" | "copy";
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
interface FileStore {
|
|
20
35
|
tree: FileNode[];
|
|
21
36
|
fileIndex: FileEntry[];
|
|
@@ -27,7 +42,14 @@ interface FileStore {
|
|
|
27
42
|
inflight: Map<string, AbortController>;
|
|
28
43
|
indexStatus: "idle" | "loading" | "ready" | "error";
|
|
29
44
|
selectedFiles: string[];
|
|
45
|
+
inlineAction: InlineAction | null;
|
|
46
|
+
clipboard: ClipboardState | null;
|
|
47
|
+
focusedPath: string | null;
|
|
30
48
|
|
|
49
|
+
setInlineAction(action: InlineAction | null): void;
|
|
50
|
+
clearInlineAction(): void;
|
|
51
|
+
setClipboard(clipboard: ClipboardState | null): void;
|
|
52
|
+
setFocusedPath(path: string | null): void;
|
|
31
53
|
loadRoot(projectName: string): Promise<void>;
|
|
32
54
|
loadChildren(projectName: string, folderPath: string): Promise<void>;
|
|
33
55
|
loadIndex(projectName: string): Promise<void>;
|
|
@@ -35,7 +57,9 @@ interface FileStore {
|
|
|
35
57
|
invalidateFolder(projectName: string, folderPath: string): Promise<void>;
|
|
36
58
|
toggleExpand(projectName: string, path: string): void;
|
|
37
59
|
setExpanded(path: string, expanded: boolean): void;
|
|
60
|
+
collapseAll(): void;
|
|
38
61
|
toggleFileSelect(path: string): void;
|
|
62
|
+
setSelectedFiles(paths: string[]): void;
|
|
39
63
|
clearSelection(): void;
|
|
40
64
|
reset(): void;
|
|
41
65
|
/** @deprecated Use loadRoot instead */
|
|
@@ -52,6 +76,14 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
52
76
|
inflight: new Map<string, AbortController>(),
|
|
53
77
|
indexStatus: "idle",
|
|
54
78
|
selectedFiles: [],
|
|
79
|
+
inlineAction: null,
|
|
80
|
+
clipboard: null,
|
|
81
|
+
focusedPath: null,
|
|
82
|
+
|
|
83
|
+
setInlineAction: (action) => set({ inlineAction: action }),
|
|
84
|
+
clearInlineAction: () => set({ inlineAction: null }),
|
|
85
|
+
setClipboard: (clipboard) => set({ clipboard }),
|
|
86
|
+
setFocusedPath: (path) => set({ focusedPath: path }),
|
|
55
87
|
|
|
56
88
|
loadRoot: async (projectName: string) => {
|
|
57
89
|
set({ loading: true, error: null });
|
|
@@ -169,18 +201,22 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
169
201
|
set({ expandedPaths: paths });
|
|
170
202
|
},
|
|
171
203
|
|
|
204
|
+
collapseAll: () => {
|
|
205
|
+
set({ expandedPaths: new Set<string>() });
|
|
206
|
+
},
|
|
207
|
+
|
|
172
208
|
toggleFileSelect: (path: string) => {
|
|
173
209
|
const current = get().selectedFiles;
|
|
174
210
|
const idx = current.indexOf(path);
|
|
175
211
|
if (idx >= 0) {
|
|
176
212
|
set({ selectedFiles: current.filter((p) => p !== path) });
|
|
177
213
|
} else {
|
|
178
|
-
|
|
179
|
-
const next = current.length >= 2 ? [current[1]!, path] : [...current, path];
|
|
180
|
-
set({ selectedFiles: next });
|
|
214
|
+
set({ selectedFiles: [...current, path] });
|
|
181
215
|
}
|
|
182
216
|
},
|
|
183
217
|
|
|
218
|
+
setSelectedFiles: (paths) => set({ selectedFiles: paths }),
|
|
219
|
+
|
|
184
220
|
clearSelection: () => set({ selectedFiles: [] }),
|
|
185
221
|
|
|
186
222
|
reset: () => {
|
|
@@ -196,6 +232,9 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
196
232
|
inflight: new Map(),
|
|
197
233
|
indexStatus: "idle",
|
|
198
234
|
selectedFiles: [],
|
|
235
|
+
inlineAction: null,
|
|
236
|
+
clipboard: null,
|
|
237
|
+
focusedPath: null,
|
|
199
238
|
});
|
|
200
239
|
},
|
|
201
240
|
|
|
@@ -205,3 +244,35 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
205
244
|
get().loadIndex(projectName);
|
|
206
245
|
},
|
|
207
246
|
}));
|
|
247
|
+
|
|
248
|
+
/** Compute flat visible path list from current tree state (for range selection) */
|
|
249
|
+
export function getVisiblePaths(): string[] {
|
|
250
|
+
const { tree, expandedPaths } = useFileStore.getState();
|
|
251
|
+
const result: string[] = [];
|
|
252
|
+
function walk(nodes: FileNode[]) {
|
|
253
|
+
const sorted = [...nodes].sort((a, b) => {
|
|
254
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
255
|
+
return a.name.localeCompare(b.name);
|
|
256
|
+
});
|
|
257
|
+
for (const n of sorted) {
|
|
258
|
+
// Skip compacted intermediate dirs (matches compact folder rendering)
|
|
259
|
+
let effective = n;
|
|
260
|
+
if (n.type === "directory" && expandedPaths.has(n.path) && n.children) {
|
|
261
|
+
while (
|
|
262
|
+
effective.children &&
|
|
263
|
+
effective.children.length === 1 &&
|
|
264
|
+
effective.children[0]!.type === "directory" &&
|
|
265
|
+
expandedPaths.has(effective.children[0]!.path)
|
|
266
|
+
) {
|
|
267
|
+
effective = effective.children[0]!;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
result.push(effective.path);
|
|
271
|
+
if (effective.type === "directory" && expandedPaths.has(effective.path) && effective.children) {
|
|
272
|
+
walk(effective.children);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
walk(tree);
|
|
277
|
+
return result;
|
|
278
|
+
}
|