@hienlh/ppm 0.17.28 → 0.17.30

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 (64) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-BCgyv1KF.js → ai-resource-editor-D1efMh5f.js} +1 -1
  5. package/dist/web/assets/{audio-preview-BPuiX8ma.js → audio-preview-BfaIfDsY.js} +1 -1
  6. package/dist/web/assets/chat-tab-CVfcHLiQ.js +13 -0
  7. package/dist/web/assets/code-editor-hKFw2M2d.js +10 -0
  8. package/dist/web/assets/{conflict-editor-BuLnwxFE.js → conflict-editor-BRDEJEq6.js} +1 -1
  9. package/dist/web/assets/{csv-preview-DdA_IpTo.js → csv-preview-D32vaKWU.js} +1 -1
  10. package/dist/web/assets/{database-viewer-BcF3uuk5.js → database-viewer-B-ukNIhW.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-tyMHX20V.js → diff-viewer-DfFzvZND.js} +1 -1
  12. package/dist/web/assets/{docx-preview-BTclTd2v.js → docx-preview-B1kTBbEc.js} +1 -1
  13. package/dist/web/assets/{extension-webview-CBLWXhjt.js → extension-webview-mQPWwlxb.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-tmGgY2kK.js → git-log-panel-CIB6xuoc.js} +1 -1
  15. package/dist/web/assets/glide-data-grid-DM0v58AP.js +138 -0
  16. package/dist/web/assets/group-chat-tab-pfDZpGO_.js +1 -0
  17. package/dist/web/assets/{image-preview-CQ57RN6l.js → image-preview-Dxvk6fiw.js} +1 -1
  18. package/dist/web/assets/index-Cp6hb7uj.js +50 -0
  19. package/dist/web/assets/keybindings-store-BDQFSrPz.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-devgS0zw.js → markdown-renderer-TCeFZDxj.js} +2 -2
  21. package/dist/web/assets/{markdown-renderer-C7clwuDe.js → markdown-renderer-zRn368SN.js} +1 -1
  22. package/dist/web/assets/notification-store-BcEM83wQ.js +1 -0
  23. package/dist/web/assets/{pdf-preview-Cw_GRd7e.js → pdf-preview-Bx_RsWdB.js} +1 -1
  24. package/dist/web/assets/{postgres-viewer-DN40Bdc-.js → postgres-viewer-DJXDXZbE.js} +2 -2
  25. package/dist/web/assets/{settings-tab-DkpOd_kU.js → settings-tab-CAJ4skP0.js} +1 -1
  26. package/dist/web/assets/{sql-query-editor-Df1pzXAj.js → sql-query-editor-UDQo7Bck.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-Dbh3LluT.js → sqlite-viewer-BaQq64OX.js} +1 -1
  28. package/dist/web/assets/{system-monitor-tab-bbct9aEk.js → system-monitor-tab-P1uy84Os.js} +1 -1
  29. package/dist/web/assets/terminal-tab-mI-vAmOV.js +3 -0
  30. package/dist/web/assets/{tool-cards-BonPvpUI.js → tool-cards-B6jz0GZE.js} +2 -2
  31. package/dist/web/assets/{use-monaco-theme-Cqo17yKA.js → use-monaco-theme-CZKIw2ZG.js} +1 -1
  32. package/dist/web/assets/{video-preview-kZgggFAn.js → video-preview-TGDSUs0J.js} +1 -1
  33. package/dist/web/index.html +1 -1
  34. package/dist/web/sw.js +1 -1
  35. package/package.json +2 -2
  36. package/src/services/supervisor.ts +45 -24
  37. package/src/services/windows-process-tree.ts +24 -3
  38. package/src/web/components/chat/account-dialogs.tsx +3 -3
  39. package/src/web/components/chat/chat-history-bar.tsx +4 -2
  40. package/src/web/components/chat/message-action-bar.tsx +2 -1
  41. package/src/web/components/database/connection-import-export.tsx +5 -2
  42. package/src/web/components/database/export-button.tsx +4 -3
  43. package/src/web/components/explorer/file-tree.tsx +3 -2
  44. package/src/web/components/layout/cloud-share-popover.tsx +2 -1
  45. package/src/web/components/layout/mobile-nav.tsx +3 -2
  46. package/src/web/components/layout/project-bottom-sheet.tsx +2 -1
  47. package/src/web/components/layout/project-switcher.tsx +2 -1
  48. package/src/web/components/layout/tab-bar.tsx +3 -2
  49. package/src/web/components/settings/accounts-settings-section.tsx +3 -3
  50. package/src/web/components/settings/proxy-settings-section.tsx +8 -7
  51. package/src/web/components/shared/bug-report-popup.tsx +2 -1
  52. package/src/web/components/shared/markdown-code-block.tsx +3 -2
  53. package/src/web/components/terminal/terminal-tab.tsx +2 -1
  54. package/src/web/components/tunnels/tunnel-manager-tab.tsx +4 -2
  55. package/src/web/lib/clipboard.ts +52 -0
  56. package/src/web/lib/report-bug.ts +0 -10
  57. package/dist/web/assets/chat-tab-WnnM6GKA.js +0 -13
  58. package/dist/web/assets/code-editor-CnGUyOuU.js +0 -10
  59. package/dist/web/assets/glide-data-grid-CkCW7sFw.js +0 -138
  60. package/dist/web/assets/group-chat-tab-D_x5CRae.js +0 -1
  61. package/dist/web/assets/index-Cp3e9olf.js +0 -50
  62. package/dist/web/assets/keybindings-store-CpgEwnFA.js +0 -1
  63. package/dist/web/assets/notification-store-BohufOcS.js +0 -1
  64. package/dist/web/assets/terminal-tab-Cqs7xZJt.js +0 -3
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
5
5
  import { Label } from "@/components/ui/label";
6
6
  import { Loader2, Download, Copy, Lock } from "lucide-react";
7
7
  import { getAuthToken } from "../../lib/api-client";
8
+ import { copyToClipboard } from "@/lib/clipboard";
8
9
  import {
9
10
  addAccount,
10
11
  getOAuthUrl,
@@ -207,10 +208,9 @@ export function ExportAccountsDialog({ open, onOpenChange, accounts, preselectId
207
208
  if (!res.ok) { const j = await res.json() as any; throw new Error(j.error ?? `Export failed: ${res.status}`); }
208
209
  const text = await res.text();
209
210
  if (toClipboard) {
210
- try {
211
- await navigator.clipboard.writeText(text);
211
+ if (await copyToClipboard(text)) {
212
212
  onMessage?.("Backup copied to clipboard!");
213
- } catch {
213
+ } else {
214
214
  downloadBlob(text);
215
215
  onMessage?.("Backup downloaded.");
216
216
  }
@@ -17,6 +17,7 @@ import { CodexUsagePanel } from "./codex-usage-panel";
17
17
  import { TeamActivityPanel } from "./team-activity-panel";
18
18
  import { ProviderBadge } from "./provider-selector";
19
19
  import { formatRelativeDate } from "@/lib/format-date";
20
+ import { copyToClipboard } from "@/lib/clipboard";
20
21
  import { useDebouncedValue } from "@/hooks/use-debounced-value";
21
22
  import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
22
23
  import type { UsageInfo } from "../../../types/chat";
@@ -113,10 +114,11 @@ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projec
113
114
 
114
115
  const copy = () => {
115
116
  if (!info) return;
116
- navigator.clipboard.writeText(info).then(() => {
117
+ copyToClipboard(info).then((ok) => {
118
+ if (!ok) return;
117
119
  setCopied(true);
118
120
  setTimeout(() => setCopied(false), 1500);
119
- }).catch(() => { /* silent */ });
121
+ });
120
122
  };
121
123
 
122
124
  const body = (
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
2
2
  import { Copy, Check } from "lucide-react";
3
3
  import { cn } from "@/lib/utils";
4
4
  import { formatRelativeDate } from "@/lib/format-date";
5
+ import { copyToClipboard } from "@/lib/clipboard";
5
6
 
6
7
  /** Full timestamp for the relative-time tooltip. */
7
8
  function fullDate(iso: string): string {
@@ -50,7 +51,7 @@ function CopyButton({ content }: { content: string }) {
50
51
  label={copied ? "Copied" : "Copy"}
51
52
  title="Copy message to clipboard"
52
53
  onClick={() => {
53
- navigator.clipboard?.writeText(content).catch(() => {});
54
+ void copyToClipboard(content);
54
55
  setCopied(true);
55
56
  setTimeout(() => setCopied(false), 1500);
56
57
  }}
@@ -1,5 +1,6 @@
1
1
  import { useRef, useState } from "react";
2
2
  import { MoreVertical, Download, Upload, Clipboard, ClipboardPaste } from "lucide-react";
3
+ import { copyToClipboard } from "@/lib/clipboard";
3
4
 
4
5
  interface Props {
5
6
  onExport: () => Promise<{ version: number; exported_at: string; connections: unknown[] }>;
@@ -32,8 +33,10 @@ export function ConnectionImportExport({ onExport, onImport }: Props) {
32
33
  close();
33
34
  try {
34
35
  const data = await onExport();
35
- await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
36
- alert(`Copied ${data.connections.length} connection(s) to clipboard`);
36
+ const ok = await copyToClipboard(JSON.stringify(data, null, 2));
37
+ alert(ok
38
+ ? `Copied ${data.connections.length} connection(s) to clipboard`
39
+ : "Copy failed — clipboard unavailable");
37
40
  } catch (e) {
38
41
  alert(`Export failed: ${(e as Error).message}`);
39
42
  }
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { Download } from "lucide-react";
4
4
  import { serializeCsv } from "@/lib/csv-parser";
5
+ import { copyToClipboard } from "@/lib/clipboard";
5
6
 
6
7
  interface ExportButtonProps {
7
8
  columns: string[];
@@ -53,10 +54,10 @@ export function ExportButton({ columns, rows, filename = "export", exportAllUrl
53
54
  setOpen(false);
54
55
  };
55
56
 
56
- const copyToClipboard = async () => {
57
+ const handleCopy = async () => {
57
58
  const header = columns.join("\t");
58
59
  const body = rows.map((r) => columns.map((c) => String(r[c] ?? "")).join("\t")).join("\n");
59
- await navigator.clipboard.writeText(header + "\n" + body);
60
+ await copyToClipboard(header + "\n" + body);
60
61
  setOpen(false);
61
62
  };
62
63
 
@@ -90,7 +91,7 @@ export function ExportButton({ columns, rows, filename = "export", exportAllUrl
90
91
  <div ref={dropdownRef}
91
92
  style={{ position: "fixed", left: Math.min(rect.left, window.innerWidth - 170), top: rect.bottom + 4, zIndex: 10000 }}
92
93
  className="bg-popover border border-border rounded-md shadow-md py-1 min-w-[160px] text-xs">
93
- <button onClick={copyToClipboard} className="w-full text-left px-3 py-1.5 hover:bg-muted transition-colors text-foreground">
94
+ <button onClick={handleCopy} className="w-full text-left px-3 py-1.5 hover:bg-muted transition-colors text-foreground">
94
95
  Copy to Clipboard (TSV)
95
96
  </button>
96
97
  <button onClick={exportPageCsv} className="w-full text-left px-3 py-1.5 hover:bg-muted transition-colors text-foreground">
@@ -13,6 +13,7 @@ import {
13
13
  Loader2,
14
14
  } from "lucide-react";
15
15
  import { SidebarHeader } from "@/components/ui/sidebar-header";
16
+ import { copyToClipboard } from "@/lib/clipboard";
16
17
  import { useShallow } from "zustand/react/shallow";
17
18
  import { useFileStore, type FileNode } from "@/stores/file-store";
18
19
  import { useProjectStore } from "@/stores/project-store";
@@ -244,12 +245,12 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
244
245
  return;
245
246
  }
246
247
  if (action === "copy-path") {
247
- navigator.clipboard.writeText(node.path).catch(() => {});
248
+ void copyToClipboard(node.path);
248
249
  return;
249
250
  }
250
251
  if (action === "copy-full-path") {
251
252
  const root = activeProject?.path;
252
- navigator.clipboard.writeText(root ? `${root}/${node.path}` : node.path).catch(() => {});
253
+ void copyToClipboard(root ? `${root}/${node.path}` : node.path);
253
254
  return;
254
255
  }
255
256
  if (action === "select-for-compare") {
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from "react";
2
2
  import { Cloud, Share2, Loader2, Copy, Check, X, LogOut, Link, Unlink, ExternalLink } from "lucide-react";
3
3
  import { QRCodeSVG } from "qrcode.react";
4
4
  import { api } from "@/lib/api-client";
5
+ import { copyToClipboard } from "@/lib/clipboard";
5
6
 
6
7
  interface CloudStatus {
7
8
  logged_in: boolean;
@@ -54,7 +55,7 @@ export function CloudSharePopover({ onClose, variant = "popover" }: Props) {
54
55
  }, []);
55
56
 
56
57
  const handleCopy = useCallback((url: string) => {
57
- navigator.clipboard.writeText(url);
58
+ void copyToClipboard(url);
58
59
  setCopied(url);
59
60
  setTimeout(() => setCopied(null), 2000);
60
61
  }, []);
@@ -14,6 +14,7 @@ import { resolveProjectColor } from "@/lib/project-palette";
14
14
  import { ProjectAvatar } from "@/components/layout/project-avatar";
15
15
  import type { Tab } from "@/stores/tab-store";
16
16
  import { cn, basename } from "@/lib/utils";
17
+ import { copyToClipboard } from "@/lib/clipboard";
17
18
  import { toast } from "sonner";
18
19
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
19
20
  import { useNotificationStore } from "@/stores/notification-store";
@@ -188,12 +189,12 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
188
189
  const projectName = tab.metadata?.projectName as string | undefined;
189
190
  switch (action) {
190
191
  case "copy-path":
191
- if (filePath) navigator.clipboard.writeText(filePath).catch(() => {});
192
+ if (filePath) void copyToClipboard(filePath);
192
193
  break;
193
194
  case "copy-full-path": {
194
195
  if (filePath) {
195
196
  const project = projectName ? useProjectStore.getState().projects.find((p) => p.name === projectName) : null;
196
- navigator.clipboard.writeText(project ? `${project.path}/${filePath}` : filePath).catch(() => {});
197
+ void copyToClipboard(project ? `${project.path}/${filePath}` : filePath);
197
198
  }
198
199
  break;
199
200
  }
@@ -10,6 +10,7 @@ import { ProjectAvatar } from "@/components/layout/project-avatar";
10
10
  import { buildUrl } from "@/hooks/use-url-sync";
11
11
  import { formatRelativeDate } from "@/lib/format-date";
12
12
  import { cn } from "@/lib/utils";
13
+ import { copyToClipboard } from "@/lib/clipboard";
13
14
  import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
14
15
 
15
16
  interface ProjectBottomSheetProps {
@@ -181,7 +182,7 @@ export function ProjectBottomSheet({ isOpen, onClose }: ProjectBottomSheetProps)
181
182
  label: "Copy Path",
182
183
  icon: Copy,
183
184
  onClick: () => {
184
- if (actionProject) navigator.clipboard.writeText(actionProject.path).catch(() => { /* ignore */ });
185
+ if (actionProject) void copyToClipboard(actionProject.path);
185
186
  setActionTarget(null);
186
187
  },
187
188
  },
@@ -6,6 +6,7 @@ import { useProjectStore, resolveOrder, loadRecentTimes, type ProjectInfo } from
6
6
  import { SORT_OPTIONS, applySort } from "@/components/layout/project-sort";
7
7
  import { buildUrl } from "@/hooks/use-url-sync";
8
8
  import { formatRelativeDate } from "@/lib/format-date";
9
+ import { copyToClipboard } from "@/lib/clipboard";
9
10
  import { resolveProjectColor, PROJECT_PALETTE } from "@/lib/project-palette";
10
11
  import { ProjectAvatar } from "@/components/layout/project-avatar";
11
12
  import { useNotificationStore, selectProjectUrgentType, notificationColor } from "@/stores/notification-store";
@@ -358,7 +359,7 @@ export const ProjectSwitcher = memo(function ProjectSwitcher() {
358
359
  <Trash2 className="size-3.5 mr-2" /> Remove Image
359
360
  </ContextMenuItem>
360
361
  )}
361
- <ContextMenuItem onClick={() => navigator.clipboard.writeText(p.path)}>
362
+ <ContextMenuItem onClick={() => void copyToClipboard(p.path)}>
362
363
  <Copy className="size-3.5 mr-2" /> Copy Path
363
364
  </ContextMenuItem>
364
365
  <ContextMenuSeparator />
@@ -43,6 +43,7 @@ import { DraggableTab } from "./draggable-tab";
43
43
  import { cn } from "@/lib/utils";
44
44
  import type { Tab } from "@/stores/tab-store";
45
45
  import { downloadFile } from "@/lib/file-download";
46
+ import { copyToClipboard } from "@/lib/clipboard";
46
47
  import { FileActions } from "@/components/explorer/file-actions";
47
48
  import {
48
49
  ContextMenu as BarContextMenu,
@@ -278,7 +279,7 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
278
279
  }
279
280
  case "copy-path": {
280
281
  const filePath = tab.metadata?.filePath as string | undefined;
281
- if (filePath) navigator.clipboard.writeText(filePath).catch(() => {});
282
+ if (filePath) void copyToClipboard(filePath);
282
283
  break;
283
284
  }
284
285
  case "copy-full-path": {
@@ -286,7 +287,7 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
286
287
  const projectName = tab.metadata?.projectName as string | undefined;
287
288
  if (filePath) {
288
289
  const project = projectName ? useProjectStore.getState().projects.find((p) => p.name === projectName) : null;
289
- navigator.clipboard.writeText(project ? `${project.path}/${filePath}` : filePath).catch(() => {});
290
+ void copyToClipboard(project ? `${project.path}/${filePath}` : filePath);
290
291
  }
291
292
  break;
292
293
  }
@@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
8
8
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
9
9
  import { Eye, Loader2, Copy, X, Download, Upload, Lock, FlaskConical } from "lucide-react";
10
10
  import { getAuthToken } from "../../lib/api-client";
11
+ import { copyToClipboard } from "@/lib/clipboard";
11
12
  import {
12
13
  getAccounts,
13
14
  getActiveAccount,
@@ -347,10 +348,9 @@ export function AccountsSettingsSection() {
347
348
  if (!res.ok) { const j = await res.json() as any; throw new Error(j.error ?? `Export failed: ${res.status}`); }
348
349
  const text = await res.text();
349
350
  if (toClipboard) {
350
- try {
351
- await navigator.clipboard.writeText(text);
351
+ if (await copyToClipboard(text)) {
352
352
  showMessage({ type: "success", text: "Backup copied to clipboard!" });
353
- } catch {
353
+ } else {
354
354
  const blob = new Blob([text], { type: "application/json" });
355
355
  const a = document.createElement("a");
356
356
  a.href = URL.createObjectURL(blob);
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
5
5
  import { Label } from "@/components/ui/label";
6
6
  import { Switch } from "@/components/ui/switch";
7
7
  import { getProxySettings, updateProxySettings, type ProxySettings } from "@/lib/api-settings";
8
+ import { copyToClipboard } from "@/lib/clipboard";
8
9
  import { ProxyTestButton } from "./proxy-test-section";
9
10
 
10
11
  export function ProxySettingsSection() {
@@ -37,8 +38,8 @@ export function ProxySettingsSection() {
37
38
  }
38
39
  };
39
40
 
40
- const copyToClipboard = (text: string, label: string) => {
41
- navigator.clipboard.writeText(text);
41
+ const handleCopy = (text: string, label: string) => {
42
+ void copyToClipboard(text);
42
43
  setCopied(label);
43
44
  setTimeout(() => setCopied(null), 2000);
44
45
  };
@@ -96,7 +97,7 @@ export function ProxySettingsSection() {
96
97
  variant="outline"
97
98
  size="sm"
98
99
  className="h-7 px-2 cursor-pointer shrink-0"
99
- onClick={() => copyToClipboard(settings.authKey!, "key")}
100
+ onClick={() => handleCopy(settings.authKey!, "key")}
100
101
  >
101
102
  {copied === "key" ? "Copied!" : <Copy className="size-3" />}
102
103
  </Button>
@@ -145,7 +146,7 @@ export function ProxySettingsSection() {
145
146
  variant="ghost"
146
147
  size="sm"
147
148
  className="h-6 px-1.5 cursor-pointer shrink-0"
148
- onClick={() => copyToClipboard(hasTunnel ? settings.proxyEndpoint! : localEndpoint, "anthropic")}
149
+ onClick={() => handleCopy(hasTunnel ? settings.proxyEndpoint! : localEndpoint, "anthropic")}
149
150
  >
150
151
  {copied === "anthropic" ? "Copied!" : <Copy className="size-3" />}
151
152
  </Button>
@@ -163,7 +164,7 @@ export function ProxySettingsSection() {
163
164
  variant="ghost"
164
165
  size="sm"
165
166
  className="h-6 px-1.5 cursor-pointer shrink-0"
166
- onClick={() => copyToClipboard(
167
+ onClick={() => handleCopy(
167
168
  hasTunnel ? settings.openAiEndpoint! : settings.localOpenAiEndpoint, "openai",
168
169
  )}
169
170
  >
@@ -190,7 +191,7 @@ ANTHROPIC_API_KEY=${settings.authKey}`}
190
191
  variant="ghost"
191
192
  size="sm"
192
193
  className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
193
- onClick={() => copyToClipboard(
194
+ onClick={() => handleCopy(
194
195
  `ANTHROPIC_BASE_URL=${hasTunnel ? settings.tunnelUrl + "/proxy" : localBaseUrl + "/proxy"}\nANTHROPIC_API_KEY=${settings.authKey}`,
195
196
  "anthropic-env",
196
197
  )}
@@ -211,7 +212,7 @@ OPENAI_API_KEY=${settings.authKey}`}
211
212
  variant="ghost"
212
213
  size="sm"
213
214
  className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
214
- onClick={() => copyToClipboard(
215
+ onClick={() => handleCopy(
215
216
  `OPENAI_BASE_URL=${hasTunnel ? settings.tunnelUrl + "/proxy/v1" : localBaseUrl + "/proxy/v1"}\nOPENAI_API_KEY=${settings.authKey}`,
216
217
  "openai-env",
217
218
  )}
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Copy, ExternalLink, X, Check } from "lucide-react";
3
- import { openGithubIssue, copyToClipboard } from "@/lib/report-bug";
3
+ import { openGithubIssue } from "@/lib/report-bug";
4
+ import { copyToClipboard } from "@/lib/clipboard";
4
5
 
5
6
  export function BugReportPopup() {
6
7
  const [text, setText] = useState<string | null>(null);
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from "react";
2
2
  import { useMdContext, FILE_EXT_RE, GLOB_CHARS_RE } from "./markdown-context";
3
3
  import { useTabStore } from "@/stores/tab-store";
4
4
  import { highlightToHtml, highlightSync, getActiveShikiTheme } from "@/theme/adapters/shiki-adapter";
5
+ import { copyToClipboard } from "@/lib/clipboard";
5
6
 
6
7
  /**
7
8
  * Shiki-highlighted HTML for a code block. Returns null while streaming or
@@ -88,13 +89,13 @@ export function MdPre({ children, node, ...rest }: any) {
88
89
  : <pre {...rest}>{children}</pre>}
89
90
  {codeActions && (
90
91
  <div className="code-actions absolute top-1 right-1 flex gap-1">
91
- <ActionBtn title="Copy" icon={<CopyIcon />} activeIcon={<CheckIcon />} onClick={() => navigator.clipboard.writeText(text)} />
92
+ <ActionBtn title="Copy" icon={<CopyIcon />} activeIcon={<CheckIcon />} onClick={() => void copyToClipboard(text)} />
92
93
  {isBash && projectName && (
93
94
  <ActionBtn
94
95
  title="Run in terminal"
95
96
  icon={<PlayIcon />}
96
97
  onClick={() => {
97
- navigator.clipboard.writeText(text.replace(/^\$\s*/gm, ""));
98
+ void copyToClipboard(text.replace(/^\$\s*/gm, ""));
98
99
  openTab({ type: "terminal", title: "Terminal", metadata: { projectName }, projectId: projectName, closable: true });
99
100
  }}
100
101
  />
@@ -1,6 +1,7 @@
1
1
  import { useRef, useEffect, useState, useCallback, memo } from "react";
2
2
  import { useTerminal } from "@/hooks/use-terminal";
3
3
  import { cn } from "@/lib/utils";
4
+ import { copyToClipboard } from "@/lib/clipboard";
4
5
  import { Copy, ClipboardPaste, RotateCcw, MessageSquare } from "lucide-react";
5
6
  import "@xterm/xterm/css/xterm.css";
6
7
  import { toast } from "sonner";
@@ -88,7 +89,7 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
88
89
  const handleCopy = useCallback(async () => {
89
90
  const selection = getSelection();
90
91
  if (selection) {
91
- await navigator.clipboard.writeText(selection);
92
+ await copyToClipboard(selection);
92
93
  }
93
94
  }, [getSelection]);
94
95
 
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Check, Copy, ExternalLink, Globe, Loader2, Lock, RefreshCw, Square } from "lucide-react";
3
3
  import { tunnelsApi, type TunnelEntry } from "@/lib/api-tunnels";
4
+ import { copyToClipboard } from "@/lib/clipboard";
4
5
  import { toast } from "sonner";
5
6
 
6
7
  /** Badge label + style per tunnel source. */
@@ -73,11 +74,12 @@ export function TunnelManagerTab() {
73
74
  };
74
75
 
75
76
  const copyUrl = (pid: number, url: string) => {
76
- navigator.clipboard.writeText(url).then(() => {
77
+ copyToClipboard(url).then((ok) => {
78
+ if (!ok) { toast.error("Failed to copy URL"); return; }
77
79
  setCopiedPid(pid);
78
80
  toast.success("URL copied");
79
81
  setTimeout(() => setCopiedPid(null), 2000);
80
- }).catch(() => toast.error("Failed to copy URL"));
82
+ });
81
83
  };
82
84
 
83
85
  const handleSubmit = (e: React.FormEvent) => {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Clipboard write with an insecure-context fallback.
3
+ *
4
+ * `navigator.clipboard` only exists in a secure context (HTTPS or localhost).
5
+ * PPM is regularly reached over plain HTTP — LAN IP, Tailscale MagicDNS name —
6
+ * where the API is absent and every copy button silently no-ops. The legacy
7
+ * `execCommand` path keeps copy working on those origins.
8
+ */
9
+
10
+ /** Copy text to the clipboard. Returns false only if both paths fail. */
11
+ export async function copyToClipboard(text: string): Promise<boolean> {
12
+ if (navigator.clipboard?.writeText) {
13
+ try {
14
+ await navigator.clipboard.writeText(text);
15
+ return true;
16
+ } catch {
17
+ // Denied permission or lost user activation — fall through to execCommand.
18
+ }
19
+ }
20
+ return legacyCopy(text);
21
+ }
22
+
23
+ function legacyCopy(text: string): boolean {
24
+ const textarea = document.createElement("textarea");
25
+ textarea.value = text;
26
+ textarea.setAttribute("readonly", "");
27
+ // Must stay rendered and non-zero sized: iOS Safari refuses to select a
28
+ // hidden or display:none element, which breaks the copy.
29
+ textarea.style.cssText =
30
+ "position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:none;opacity:0;";
31
+ document.body.appendChild(textarea);
32
+
33
+ const selection = document.getSelection();
34
+ const previousRange =
35
+ selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
36
+
37
+ try {
38
+ textarea.focus();
39
+ textarea.select();
40
+ // setSelectionRange is what actually selects on iOS; select() alone is ignored.
41
+ textarea.setSelectionRange(0, text.length);
42
+ return document.execCommand("copy");
43
+ } catch {
44
+ return false;
45
+ } finally {
46
+ textarea.remove();
47
+ if (selection && previousRange) {
48
+ selection.removeAllRanges();
49
+ selection.addRange(previousRange);
50
+ }
51
+ }
52
+ }
@@ -60,16 +60,6 @@ export function openGithubIssue(body: string) {
60
60
  window.open(url, "_blank");
61
61
  }
62
62
 
63
- /** Copy text to clipboard */
64
- export async function copyToClipboard(text: string): Promise<boolean> {
65
- try {
66
- await navigator.clipboard.writeText(text);
67
- return true;
68
- } catch {
69
- return false;
70
- }
71
- }
72
-
73
63
  /** Open bug report popup globally via custom event */
74
64
  export function openBugReportPopup(
75
65
  version: string | null,