@hienlh/ppm 0.11.2 → 0.11.4

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.
Files changed (23) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/web/assets/{chat-tab-DYf6U6UF.js → chat-tab-BNOBNzph.js} +6 -6
  3. package/dist/web/assets/{code-editor-BPxBeu0S.js → code-editor--pNv4j1V.js} +2 -2
  4. package/dist/web/assets/{conflict-editor-BCkYHDUy.js → conflict-editor-BGoXs-1S.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CCe8qa1Q.js → database-viewer-B9H3s0Xv.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-DIjzWvaG.js → diff-viewer-CwL9hvFQ.js} +1 -1
  7. package/dist/web/assets/{extension-webview-HY8XueLo.js → extension-webview-dVgax0Uo.js} +1 -1
  8. package/dist/web/assets/index--W7nYakj.js +26 -0
  9. package/dist/web/assets/{markdown-renderer-BQV0AIm5.js → markdown-renderer-BLksGuhd.js} +1 -1
  10. package/dist/web/assets/{port-forwarding-tab-DPmTpfFX.js → port-forwarding-tab-CezzkDTK.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-BUSNt_7x.js → postgres-viewer-DkZHOBg1.js} +1 -1
  12. package/dist/web/assets/{settings-tab-DHBG5O0C.js → settings-tab-plcYxjwZ.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-B7WnFN29.js → sqlite-viewer-BmKCNAih.js} +1 -1
  14. package/dist/web/assets/{terminal-tab-1K4ijyNe.js → terminal-tab-B0GxiD3-.js} +1 -1
  15. package/dist/web/index.html +1 -1
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/server/routes/files.ts +39 -1
  19. package/src/server/ws/chat.ts +23 -1
  20. package/src/web/components/chat/tool-cards.tsx +2 -1
  21. package/src/web/components/explorer/file-tree.tsx +107 -4
  22. package/src/web/hooks/use-chat.ts +29 -4
  23. package/dist/web/assets/index-DpRxWGjM.js +0 -26
@@ -69,10 +69,11 @@ export function ToolCard({
69
69
  const hasResult = result?.type === "tool_result";
70
70
  const isError = hasResult && !!(result as any).isError;
71
71
  const hasAnswers = toolName === "AskUserQuestion" && !!(input as any)?.answers;
72
+ const wasApproved = tool.type === "approval_request" && (tool as any).approved != null;
72
73
  const isSubagent = (toolName === "Agent" || toolName === "Task") && tool.type === "tool_use";
73
74
  const children = isSubagent ? (tool as any).children as ChatEvent[] | undefined : undefined;
74
75
  const hasChildren = children && children.length > 0;
75
- const isDone = hasResult || hasAnswers || completed;
76
+ const isDone = hasResult || hasAnswers || wasApproved || completed;
76
77
 
77
78
  return (
78
79
  <div className={`rounded border text-xs ${isSubagent ? "border-accent/30 bg-accent/5" : "border-border bg-background"}`}>
@@ -1,4 +1,4 @@
1
- import { useEffect, useCallback, useState, memo } from "react";
1
+ import { useEffect, useCallback, useState, useRef, memo } from "react";
2
2
  import {
3
3
  Folder,
4
4
  FolderOpen,
@@ -30,6 +30,12 @@ import {
30
30
  } from "@/components/ui/context-menu";
31
31
  import { FileActions } from "./file-actions";
32
32
  import { downloadFile, downloadFolder } from "@/lib/file-download";
33
+ import { getAuthToken, projectUrl } from "@/lib/api-client";
34
+
35
+ /** Check if drag event is from OS files (not internal PPM drag) */
36
+ function isExternalFileDrag(e: React.DragEvent): boolean {
37
+ return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
38
+ }
33
39
 
34
40
  /** Synthetic root node for creating files/folders at project root */
35
41
  const ROOT_NODE: FileNode = { name: "", path: "", type: "directory" };
@@ -62,16 +68,19 @@ interface TreeNodeProps {
62
68
  depth: number;
63
69
  projectName: string;
64
70
  onAction: (action: string, node: FileNode) => void;
71
+ onFileDrop: (targetDir: string, files: FileList) => void;
65
72
  onFileOpen?: () => void;
66
73
  }
67
74
 
68
- const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileOpen }: TreeNodeProps) {
75
+ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
69
76
  const { expandedPaths, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore(useShallow((s) => ({ expandedPaths: s.expandedPaths, toggleExpand: s.toggleExpand, selectedFiles: s.selectedFiles, toggleFileSelect: s.toggleFileSelect })));
70
77
  const openTab = useTabStore((s) => s.openTab);
71
78
  const isExpanded = expandedPaths.has(node.path);
72
79
  const isDir = node.type === "directory";
73
80
  const isSelected = selectedFiles.includes(node.path);
74
81
  const isIgnored = node.ignored === true;
82
+ const [isDragOver, setIsDragOver] = useState(false);
83
+ const dragCounter = useRef(0);
75
84
 
76
85
  function handleClick(e: React.MouseEvent) {
77
86
  if (isDir) {
@@ -102,6 +111,36 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
102
111
  e.dataTransfer.effectAllowed = "copy";
103
112
  }
104
113
 
114
+ function handleNodeDragEnter(e: React.DragEvent) {
115
+ if (!isDir || !isExternalFileDrag(e)) return;
116
+ e.preventDefault();
117
+ e.stopPropagation();
118
+ dragCounter.current++;
119
+ if (dragCounter.current === 1) setIsDragOver(true);
120
+ }
121
+ function handleNodeDragLeave(e: React.DragEvent) {
122
+ if (!isDir) return;
123
+ e.stopPropagation();
124
+ dragCounter.current--;
125
+ if (dragCounter.current === 0) setIsDragOver(false);
126
+ }
127
+ function handleNodeDragOver(e: React.DragEvent) {
128
+ if (!isDir || !isExternalFileDrag(e)) return;
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ e.dataTransfer.dropEffect = "copy";
132
+ }
133
+ function handleNodeDrop(e: React.DragEvent) {
134
+ if (!isDir || !isExternalFileDrag(e)) return;
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ dragCounter.current = 0;
138
+ setIsDragOver(false);
139
+ if (e.dataTransfer.files.length > 0) {
140
+ onFileDrop(node.path, e.dataTransfer.files);
141
+ }
142
+ }
143
+
105
144
  const Icon = isDir
106
145
  ? isExpanded
107
146
  ? FolderOpen
@@ -116,7 +155,12 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
116
155
  : [];
117
156
 
118
157
  return (
119
- <div>
158
+ <div
159
+ onDragEnter={isDir ? handleNodeDragEnter : undefined}
160
+ onDragLeave={isDir ? handleNodeDragLeave : undefined}
161
+ onDragOver={isDir ? handleNodeDragOver : undefined}
162
+ onDrop={isDir ? handleNodeDrop : undefined}
163
+ >
120
164
  <ContextMenu>
121
165
  <ContextMenuTrigger asChild>
122
166
  <button
@@ -129,6 +173,7 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
129
173
  "select-none",
130
174
  isIgnored && "opacity-40",
131
175
  isSelected && "bg-primary/15 ring-1 ring-primary/40",
176
+ isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
132
177
  )}
133
178
  style={{ paddingLeft: `${depth * 16 + 8}px` }}
134
179
  >
@@ -198,6 +243,7 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
198
243
  depth={depth + 1}
199
244
  projectName={projectName}
200
245
  onAction={onAction}
246
+ onFileDrop={onFileDrop}
201
247
  onFileOpen={onFileOpen}
202
248
  />
203
249
  ))}
@@ -239,6 +285,56 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
239
285
  return () => window.removeEventListener("focus", handleFocus);
240
286
  }, [activeProject, fetchTree]);
241
287
 
288
+ const uploadFiles = useCallback(async (targetDir: string, files: FileList) => {
289
+ if (!activeProject) return;
290
+ const form = new FormData();
291
+ form.append("targetDir", targetDir);
292
+ for (const file of files) form.append("files", file);
293
+ const headers: HeadersInit = {};
294
+ const token = getAuthToken();
295
+ if (token) headers["Authorization"] = `Bearer ${token}`;
296
+ try {
297
+ const res = await fetch(`${projectUrl(activeProject.name)}/files/upload`, {
298
+ method: "POST",
299
+ headers,
300
+ body: form,
301
+ });
302
+ if (!res.ok) {
303
+ const json = await res.json();
304
+ console.error("Upload failed:", json.error);
305
+ }
306
+ loadTree();
307
+ } catch (e) {
308
+ console.error("Upload error:", e);
309
+ }
310
+ }, [activeProject, loadTree]);
311
+
312
+ const [isRootDragOver, setIsRootDragOver] = useState(false);
313
+ const rootDragCounter = useRef(0);
314
+
315
+ function handleRootDragEnter(e: React.DragEvent) {
316
+ if (!isExternalFileDrag(e)) return;
317
+ e.preventDefault();
318
+ rootDragCounter.current++;
319
+ if (rootDragCounter.current === 1) setIsRootDragOver(true);
320
+ }
321
+ function handleRootDragLeave() {
322
+ rootDragCounter.current--;
323
+ if (rootDragCounter.current === 0) setIsRootDragOver(false);
324
+ }
325
+ function handleRootDragOver(e: React.DragEvent) {
326
+ if (!isExternalFileDrag(e)) return;
327
+ e.preventDefault();
328
+ e.dataTransfer.dropEffect = "copy";
329
+ }
330
+ function handleRootDrop(e: React.DragEvent) {
331
+ if (!isExternalFileDrag(e)) return;
332
+ e.preventDefault();
333
+ rootDragCounter.current = 0;
334
+ setIsRootDragOver(false);
335
+ if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
336
+ }
337
+
242
338
  function handleAction(action: string, node: FileNode) {
243
339
  if (action === "copy-path") {
244
340
  navigator.clipboard.writeText(node.path).catch(() => {});
@@ -310,7 +406,13 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
310
406
  const toolbarBtnClass = "p-1 rounded-sm text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors";
311
407
 
312
408
  return (
313
- <div className="flex flex-col h-full">
409
+ <div
410
+ className={cn("flex flex-col h-full", isRootDragOver && "bg-primary/5")}
411
+ onDragEnter={handleRootDragEnter}
412
+ onDragLeave={handleRootDragLeave}
413
+ onDragOver={handleRootDragOver}
414
+ onDrop={handleRootDrop}
415
+ >
314
416
  {/* Toolbar */}
315
417
  <div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0">
316
418
  <button onClick={() => handleAction("new-file", ROOT_NODE)} title="New File" className={toolbarBtnClass}>
@@ -337,6 +439,7 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
337
439
  depth={0}
338
440
  projectName={activeProject.name}
339
441
  onAction={handleAction}
442
+ onFileDrop={uploadFiles}
340
443
  onFileOpen={onFileOpen}
341
444
  />
342
445
  ))}
@@ -88,6 +88,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
88
88
  const pendingMessageRef = useRef<string | null>(null);
89
89
  const sendRef = useRef<(data: string) => void>(() => {});
90
90
  const refetchRef = useRef<(() => void) | null>(null);
91
+ /** True while replaying turn_events — suppresses setPendingApproval */
92
+ const isReplayingRef = useRef(false);
91
93
  const sessionIdRef = useRef(sessionId);
92
94
  sessionIdRef.current = sessionId;
93
95
  const projectNameRef = useRef(projectName);
@@ -251,6 +253,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
251
253
 
252
254
  case "approval_request": {
253
255
  streamingEventsRef.current.push(ev as ChatEvent);
256
+ // During turn_events replay, session_state already set the correct
257
+ // pendingApproval — skip re-setting it for historical (already-answered) events
258
+ if (isReplayingRef.current) break;
254
259
  setPendingApproval({
255
260
  requestId: ev.requestId,
256
261
  tool: ev.tool,
@@ -457,12 +462,30 @@ export function useChat(sessionId: string | null, providerId = "claude", project
457
462
  // Handle turn_events (reconnect sync with rAF chunking)
458
463
  if ((data as any).type === "turn_events") {
459
464
  const events = (data as any).events as unknown[];
460
- if (!events?.length) { setIsReconnecting(false); return; }
465
+ const userMessage = (data as any).userMessage as string | null;
466
+ if (!events?.length && !userMessage) { setIsReconnecting(false); return; }
461
467
 
462
- // Truncate messages after last user message
468
+ // Remove stale streaming assistant message + inject current turn's user message
463
469
  setMessages(prev => {
464
- const lastUserIdx = prev.findLastIndex(m => m.role === "user");
465
- return lastUserIdx >= 0 ? prev.slice(0, lastUserIdx + 1) : prev;
470
+ let updated = prev;
471
+ // Only remove in-progress streaming assistant (not finalized or REST-loaded)
472
+ const last = updated[updated.length - 1];
473
+ if (last?.role === "assistant" && last.id.startsWith("streaming-")) {
474
+ updated = updated.slice(0, -1);
475
+ }
476
+ // Add the current turn's user message if not already present
477
+ if (userMessage) {
478
+ const lastAfter = updated[updated.length - 1];
479
+ if (lastAfter?.role !== "user" || lastAfter.content !== userMessage) {
480
+ updated = [...updated, {
481
+ id: `user-replay-${Date.now()}`,
482
+ role: "user" as const,
483
+ content: userMessage,
484
+ timestamp: new Date().toISOString(),
485
+ }];
486
+ }
487
+ }
488
+ return updated;
466
489
  });
467
490
 
468
491
  // Reset streaming refs
@@ -471,6 +494,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
471
494
  streamingAccountRef.current = null;
472
495
 
473
496
  // Process events in chunks via requestAnimationFrame to avoid blocking main thread
497
+ isReplayingRef.current = true;
474
498
  const CHUNK_SIZE = 100;
475
499
  let offset = 0;
476
500
  const processChunk = () => {
@@ -482,6 +506,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
482
506
  if (offset < events.length) {
483
507
  requestAnimationFrame(processChunk);
484
508
  } else {
509
+ isReplayingRef.current = false;
485
510
  setIsReconnecting(false);
486
511
  }
487
512
  };