@hienlh/ppm 0.11.1 → 0.11.3

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 (22) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/web/assets/{chat-tab-DYf6U6UF.js → chat-tab-1Khvyjvx.js} +3 -3
  3. package/dist/web/assets/{code-editor-BPxBeu0S.js → code-editor-BtOD0A1r.js} +2 -2
  4. package/dist/web/assets/{conflict-editor-BCkYHDUy.js → conflict-editor-DsLm2d9j.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CCe8qa1Q.js → database-viewer-Bf-FjggD.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-DIjzWvaG.js → diff-viewer-DZcLi76X.js} +1 -1
  7. package/dist/web/assets/{extension-webview-HY8XueLo.js → extension-webview-Dt50AKjl.js} +1 -1
  8. package/dist/web/assets/index-CwVBevJP.js +26 -0
  9. package/dist/web/assets/{markdown-renderer-BQV0AIm5.js → markdown-renderer-INc5L7kL.js} +1 -1
  10. package/dist/web/assets/{port-forwarding-tab-DPmTpfFX.js → port-forwarding-tab-Ds8rV7YG.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-BUSNt_7x.js → postgres-viewer-Cvedgnv5.js} +1 -1
  12. package/dist/web/assets/{settings-tab-DHBG5O0C.js → settings-tab-D6jFbyjn.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-B7WnFN29.js → sqlite-viewer-CXbf9WUf.js} +1 -1
  14. package/dist/web/assets/{terminal-tab-1K4ijyNe.js → terminal-tab-CGu6B-nW.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/providers/claude-agent-sdk.ts +6 -9
  19. package/src/server/routes/files.ts +39 -1
  20. package/src/services/account.service.ts +9 -5
  21. package/src/web/components/explorer/file-tree.tsx +107 -4
  22. package/dist/web/assets/index-DpRxWGjM.js +0 -26
@@ -527,8 +527,10 @@ class AccountService {
527
527
  * Also skips the OAuth call if the DB token was already refreshed by another session.
528
528
  * @param disableOnFail - if true, disable the account when refresh fails (default: true).
529
529
  * Background/startup refresh should pass false to avoid disabling accounts prematurely.
530
+ * @param force - if true, bypass the skip-if-fresh check (use after 401 errors where
531
+ * the token is demonstrably invalid despite having a future expiresAt).
530
532
  */
531
- async refreshAccessToken(accountId: string, disableOnFail = true): Promise<void> {
533
+ async refreshAccessToken(accountId: string, disableOnFail = true, force = false): Promise<void> {
532
534
  // Dedup: if a refresh is already in progress for this account, wait for it instead of racing
533
535
  const pending = this.pendingRefreshes.get(accountId);
534
536
  if (pending) {
@@ -536,7 +538,7 @@ class AccountService {
536
538
  return pending;
537
539
  }
538
540
 
539
- const promise = this._doRefreshAccessToken(accountId, disableOnFail);
541
+ const promise = this._doRefreshAccessToken(accountId, disableOnFail, force);
540
542
  this.pendingRefreshes.set(accountId, promise);
541
543
  try {
542
544
  await promise;
@@ -545,16 +547,18 @@ class AccountService {
545
547
  }
546
548
  }
547
549
 
548
- private async _doRefreshAccessToken(accountId: string, disableOnFail: boolean): Promise<void> {
550
+ private async _doRefreshAccessToken(accountId: string, disableOnFail: boolean, force = false): Promise<void> {
549
551
  const account = this.getWithTokens(accountId);
550
552
  if (!account) throw new Error(`Account ${accountId} not found`);
551
553
  // Skip refresh for temporary accounts (no refresh token)
552
554
  if (!account.refreshToken || account.refreshToken === "") {
553
555
  throw new Error(`Account ${accountId} has no refresh token (temporary account)`);
554
556
  }
555
- // Skip if token was already refreshed by another session (still fresh)
557
+ // Skip if token was already refreshed by another session (still fresh).
558
+ // But when force=true (after a 401), always refresh — the token may be
559
+ // revoked server-side despite having a future expiresAt.
556
560
  const nowS = Math.floor(Date.now() / 1000);
557
- if (account.expiresAt && account.expiresAt - nowS > 60) {
561
+ if (!force && account.expiresAt && account.expiresAt - nowS > 60) {
558
562
  console.log(`[accounts] Token for ${account.email ?? accountId} is already fresh (expires in ${account.expiresAt - nowS}s) — skipping OAuth refresh`);
559
563
  return;
560
564
  }
@@ -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
  ))}