@hienlh/ppm 0.17.29 → 0.17.31
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 +17 -0
- package/README.md +2 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +3 -1
- package/dist/web/assets/{ai-resource-editor-BCgyv1KF.js → ai-resource-editor-ByZApv3Y.js} +1 -1
- package/dist/web/assets/{audio-preview-BPuiX8ma.js → audio-preview-izB0KGPQ.js} +1 -1
- package/dist/web/assets/chat-tab-CwWL5DbD.js +13 -0
- package/dist/web/assets/code-editor-Bx-Q27Nm.js +10 -0
- package/dist/web/assets/{conflict-editor-BuLnwxFE.js → conflict-editor-B60UimS8.js} +1 -1
- package/dist/web/assets/{csv-preview-DdA_IpTo.js → csv-preview-BZv2SuCz.js} +1 -1
- package/dist/web/assets/{database-viewer-BcF3uuk5.js → database-viewer-B5J_4lvX.js} +1 -1
- package/dist/web/assets/{diff-viewer-tyMHX20V.js → diff-viewer-DiytoNvX.js} +1 -1
- package/dist/web/assets/{docx-preview-BTclTd2v.js → docx-preview-CrjmF16z.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-DDLG3m68.js → edit-diff-preview-BSjgyGAL.js} +5 -10
- package/dist/web/assets/{extension-webview-CBLWXhjt.js → extension-webview-BSiQUxFD.js} +1 -1
- package/dist/web/assets/{git-log-panel-tmGgY2kK.js → git-log-panel-Fb5EefXW.js} +1 -1
- package/dist/web/assets/glide-data-grid-Cjve7mDb.js +138 -0
- package/dist/web/assets/group-chat-tab-DT0X7Cbz.js +1 -0
- package/dist/web/assets/{image-preview-CQ57RN6l.js → image-preview-BiVYVqa9.js} +1 -1
- package/dist/web/assets/index-BW2cOdzp.css +2 -0
- package/dist/web/assets/index-CuAijZGh.js +50 -0
- package/dist/web/assets/keybindings-store-sakq3gsF.js +1 -0
- package/dist/web/assets/libesm-Dd8zbj1v.js +6 -0
- package/dist/web/assets/{markdown-renderer-devgS0zw.js → markdown-renderer-BsKIIM83.js} +2 -2
- package/dist/web/assets/{markdown-renderer-C7clwuDe.js → markdown-renderer-Cct9JHnx.js} +1 -1
- package/dist/web/assets/notification-store-D4TsAnN3.js +1 -0
- package/dist/web/assets/{pdf-preview-Cw_GRd7e.js → pdf-preview-B6G1QpE6.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DN40Bdc-.js → postgres-viewer-CdFu2Yj9.js} +2 -2
- package/dist/web/assets/{settings-store-UwT_fNhg.js → settings-store-DFMz_Oer.js} +2 -2
- package/dist/web/assets/settings-tab-Dh3bD_dg.js +1 -0
- package/dist/web/assets/{sql-query-editor-Df1pzXAj.js → sql-query-editor-C8XXOY2R.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Dbh3LluT.js → sqlite-viewer-B1eXvaLK.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-bbct9aEk.js → system-monitor-tab-D2QcCXl1.js} +1 -1
- package/dist/web/assets/terminal-tab-D2-buBAQ.js +3 -0
- package/dist/web/assets/tool-cards-DDxShmxN.js +7 -0
- package/dist/web/assets/{use-is-mobile-CE6AamG6.js → use-is-mobile-BcuCjH19.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-Cqo17yKA.js → use-monaco-theme-DMlVAYKD.js} +1 -1
- package/dist/web/assets/{video-preview-kZgggFAn.js → video-preview-Cg3A7vrt.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/index.ts +6 -0
- package/src/server/routes/cloud.ts +35 -0
- package/src/web/components/chat/account-dialogs.tsx +3 -3
- package/src/web/components/chat/change-edit-list.tsx +50 -0
- package/src/web/components/chat/change-file-row.tsx +99 -0
- package/src/web/components/chat/chat-history-bar.tsx +4 -2
- package/src/web/components/chat/jump-to-edit.ts +89 -0
- package/src/web/components/chat/message-action-bar.tsx +2 -1
- package/src/web/components/chat/message-list.tsx +50 -3
- package/src/web/components/chat/tool-cards.tsx +16 -6
- package/src/web/components/chat/turn-change-pill.tsx +57 -0
- package/src/web/components/chat/turn-change-rollup.tsx +76 -0
- package/src/web/components/chat/turn-change-sheet.tsx +178 -0
- package/src/web/components/chat/turn-change-tray.tsx +140 -0
- package/src/web/components/database/connection-import-export.tsx +5 -2
- package/src/web/components/database/export-button.tsx +4 -3
- package/src/web/components/explorer/file-tree.tsx +3 -2
- package/src/web/components/layout/cloud-alias-row.tsx +163 -0
- package/src/web/components/layout/cloud-share-popover.tsx +34 -21
- package/src/web/components/layout/command-palette.tsx +2 -0
- package/src/web/components/layout/mobile-drawer.tsx +10 -0
- package/src/web/components/layout/mobile-nav.tsx +3 -2
- package/src/web/components/layout/nav-section-rail.tsx +9 -0
- package/src/web/components/layout/project-bottom-sheet.tsx +2 -1
- package/src/web/components/layout/project-switcher.tsx +2 -1
- package/src/web/components/layout/tab-bar.tsx +3 -2
- package/src/web/components/settings/accounts-settings-section.tsx +3 -3
- package/src/web/components/settings/proxy-settings-section.tsx +8 -7
- package/src/web/components/settings/settings-tab.tsx +4 -4
- package/src/web/components/shared/bug-report-popup.tsx +2 -1
- package/src/web/components/shared/markdown-code-block.tsx +3 -2
- package/src/web/components/terminal/terminal-tab.tsx +2 -1
- package/src/web/components/tunnels/tunnel-manager-tab.tsx +4 -2
- package/src/web/lib/aggregate-turn-file-changes.ts +193 -0
- package/src/web/lib/clipboard.ts +52 -0
- package/src/web/lib/report-bug.ts +0 -10
- package/src/web/styles/globals.css +16 -0
- package/dist/web/assets/chat-tab-WnnM6GKA.js +0 -13
- package/dist/web/assets/code-editor-CnGUyOuU.js +0 -10
- package/dist/web/assets/glide-data-grid-CkCW7sFw.js +0 -138
- package/dist/web/assets/group-chat-tab-D_x5CRae.js +0 -1
- package/dist/web/assets/index-Cp3e9olf.js +0 -50
- package/dist/web/assets/index-DCJ29LBG.css +0 -2
- package/dist/web/assets/keybindings-store-CpgEwnFA.js +0 -1
- package/dist/web/assets/notification-store-BohufOcS.js +0 -1
- package/dist/web/assets/settings-tab-DkpOd_kU.js +0 -1
- package/dist/web/assets/terminal-tab-Cqs7xZJt.js +0 -3
- package/dist/web/assets/tool-cards-BonPvpUI.js +0 -7
- /package/dist/web/assets/{api-themes-eWBIYCbh.js → api-themes-BpoKUPkQ.js} +0 -0
- /package/dist/web/assets/{csv-parser-BOlh-1bz.js → csv-parser-BSnYgPcx.js} +0 -0
- /package/dist/web/assets/{lib-CvYn0SaB.js → lib-DA3QSc5_.js} +0 -0
- /package/dist/web/assets/{use-blob-url-BMZ0zo_4.js → use-blob-url-kvtWmJtt.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-Dlg984wj.js → vendor-xterm-CsgYwG03.js} +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop presentation of the change tray: an inline, non-modal two-pane panel
|
|
3
|
+
* directly below the message's action bar. Left pane lists the turn's files, right
|
|
4
|
+
* pane shows the selected file's edits.
|
|
5
|
+
*
|
|
6
|
+
* Both panes need `min-h-0` or the grid rows refuse to shrink and neither scrolls.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
9
|
+
import { X } from "lucide-react";
|
|
10
|
+
import { copyToClipboard } from "@/lib/clipboard";
|
|
11
|
+
import { ownsGlobalShortcut } from "@/lib/owns-global-shortcut";
|
|
12
|
+
import type { TurnFileChange } from "@/lib/aggregate-turn-file-changes";
|
|
13
|
+
import { ChangeCounts, ChangeFileRow } from "./change-file-row";
|
|
14
|
+
import { ChangeEditList } from "./change-edit-list";
|
|
15
|
+
|
|
16
|
+
function splitPath(path: string): { base: string; dir: string } {
|
|
17
|
+
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
18
|
+
return i < 0 ? { base: path, dir: "" } : { base: path.slice(i + 1), dir: path.slice(0, i) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Several messages in one chat can have a tray open at once, and `ownsGlobalShortcut`
|
|
23
|
+
* only narrows to the focused *tab*. The most recently opened tray claims the keys.
|
|
24
|
+
*/
|
|
25
|
+
let activeTrayId: string | null = null;
|
|
26
|
+
|
|
27
|
+
/** `ownsGlobalShortcut` resolves the owning tab; it says nothing about text entry. */
|
|
28
|
+
function isTextEntry(el: Element | null): boolean {
|
|
29
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
30
|
+
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function TurnChangeTray({ changes, onJump, onClose }: {
|
|
34
|
+
changes: TurnFileChange[];
|
|
35
|
+
onJump: (editRef: string) => void;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
}) {
|
|
38
|
+
// The desktop tray never shows a bare list — a file is always selected.
|
|
39
|
+
const [selected, setSelected] = useState(0);
|
|
40
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const trayId = useId();
|
|
43
|
+
|
|
44
|
+
// Inline and non-modal: focus moves in, but is deliberately not trapped.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
activeTrayId = trayId;
|
|
47
|
+
listRef.current?.querySelector<HTMLElement>("button")?.focus();
|
|
48
|
+
return () => {
|
|
49
|
+
if (activeTrayId === trayId) activeTrayId = null;
|
|
50
|
+
};
|
|
51
|
+
}, [trayId]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
55
|
+
if (activeTrayId !== trayId) return;
|
|
56
|
+
if (!ownsGlobalShortcut(containerRef.current)) return;
|
|
57
|
+
if (isTextEntry(document.activeElement)) return;
|
|
58
|
+
|
|
59
|
+
if (e.key === "Escape") {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
onClose();
|
|
62
|
+
} else if (e.key === "j") {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
setSelected((i) => Math.min(i + 1, changes.length - 1));
|
|
65
|
+
} else if (e.key === "k") {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
setSelected((i) => Math.max(i - 1, 0));
|
|
68
|
+
} else if (e.key === "Enter") {
|
|
69
|
+
const editRef = changes[selected]?.edits[0]?.editRef;
|
|
70
|
+
if (editRef) {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
onJump(editRef);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
window.addEventListener("keydown", onKeyDown);
|
|
77
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
78
|
+
}, [trayId, changes, selected, onJump, onClose]);
|
|
79
|
+
|
|
80
|
+
const active = changes[Math.min(selected, changes.length - 1)];
|
|
81
|
+
if (!active) return null;
|
|
82
|
+
const { base, dir } = splitPath(active.filePath);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div ref={containerRef} className="mt-0.5 overflow-hidden rounded-[10px] border border-border-soft bg-bg">
|
|
86
|
+
<div className="grid h-[356px] grid-cols-[294px_1fr] grid-rows-[minmax(0,1fr)] bg-surface-elevated">
|
|
87
|
+
<div ref={listRef} className="min-h-0 overflow-y-auto border-r border-border-soft">
|
|
88
|
+
{changes.map((change, i) => (
|
|
89
|
+
<ChangeFileRow
|
|
90
|
+
key={change.filePath}
|
|
91
|
+
change={change}
|
|
92
|
+
dense
|
|
93
|
+
selected={i === selected}
|
|
94
|
+
onClick={() => setSelected(i)}
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
97
|
+
<p className="px-2.5 pt-2 pb-2.5 font-mono text-[10px] text-text-subtle">
|
|
98
|
+
j / k to move · enter to jump
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="min-h-0 overflow-y-auto">
|
|
103
|
+
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border-soft bg-surface-elevated py-[5px] pl-2.5 pr-2">
|
|
104
|
+
<span className="shrink-0 font-mono text-xs font-medium text-text-primary">{base}</span>
|
|
105
|
+
{dir && (
|
|
106
|
+
<span
|
|
107
|
+
title={active.filePath}
|
|
108
|
+
className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[10.5px] text-text-subtle [direction:rtl] text-left"
|
|
109
|
+
>
|
|
110
|
+
{dir}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
<ChangeCounts
|
|
114
|
+
added={active.linesAdded}
|
|
115
|
+
removed={active.linesRemoved}
|
|
116
|
+
className="shrink-0 font-mono text-[11px]"
|
|
117
|
+
/>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => void copyToClipboard(active.filePath)}
|
|
121
|
+
className="shrink-0 rounded-md px-1.5 py-1 font-mono text-[10px] text-text-subtle transition-colors hover:bg-surface hover:text-text-primary"
|
|
122
|
+
>
|
|
123
|
+
copy path
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={onClose}
|
|
128
|
+
aria-label="Close change tray"
|
|
129
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-text-subtle transition-colors hover:bg-surface hover:text-text-primary"
|
|
130
|
+
>
|
|
131
|
+
<X className="size-3.5" />
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<ChangeEditList change={active} onJump={onJump} />
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -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
|
|
36
|
-
alert(
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
|
|
248
|
+
void copyToClipboard(node.path);
|
|
248
249
|
return;
|
|
249
250
|
}
|
|
250
251
|
if (action === "copy-full-path") {
|
|
251
252
|
const root = activeProject?.path;
|
|
252
|
-
|
|
253
|
+
void copyToClipboard(root ? `${root}/${node.path}` : node.path);
|
|
253
254
|
return;
|
|
254
255
|
}
|
|
255
256
|
if (action === "select-for-compare") {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Loader2, Pencil, ExternalLink, Check, X } from "lucide-react";
|
|
3
|
+
import { api } from "@/lib/api-client";
|
|
4
|
+
|
|
5
|
+
/** Cloud's slug rules — mirrored here so a bad name fails before a round-trip. */
|
|
6
|
+
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
|
7
|
+
const SLUG_HINT = "2–32 characters: lowercase letters, numbers, hyphens.";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Base URL of the cloud instance, e.g. https://cloud.ppm.sh */
|
|
11
|
+
cloudUrl: string;
|
|
12
|
+
/** Machine name — seeds the input with a sensible default slug. */
|
|
13
|
+
deviceName: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Strip the scheme so the permanent link reads like an address, not a URL. */
|
|
17
|
+
function displayHost(cloudUrl: string): string {
|
|
18
|
+
return cloudUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function suggestSlug(deviceName: string | null): string {
|
|
22
|
+
if (!deviceName) return "";
|
|
23
|
+
const slug = deviceName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
24
|
+
return SLUG_REGEX.test(slug) ? slug : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Permanent link for this machine. The tunnel URL rotates on every restart;
|
|
29
|
+
* the alias is the stable address that always resolves to the current one.
|
|
30
|
+
*/
|
|
31
|
+
export function CloudAliasRow({ cloudUrl, deviceName }: Props) {
|
|
32
|
+
const [slug, setSlug] = useState<string | null>(null);
|
|
33
|
+
const [loading, setLoading] = useState(true);
|
|
34
|
+
const [unreadable, setUnreadable] = useState(false);
|
|
35
|
+
const [editing, setEditing] = useState(false);
|
|
36
|
+
const [draft, setDraft] = useState("");
|
|
37
|
+
const [saving, setSaving] = useState(false);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
(async () => {
|
|
42
|
+
try {
|
|
43
|
+
const res = await api.get<{ slug: string | null }>("/api/cloud/alias");
|
|
44
|
+
setSlug(res.slug);
|
|
45
|
+
} catch {
|
|
46
|
+
// A failed read must not look like "no link yet": saving uses the device
|
|
47
|
+
// secret, so it would succeed and silently replace a link already in use.
|
|
48
|
+
setUnreadable(true);
|
|
49
|
+
}
|
|
50
|
+
setLoading(false);
|
|
51
|
+
})();
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const startEdit = useCallback(() => {
|
|
55
|
+
setDraft(slug ?? suggestSlug(deviceName));
|
|
56
|
+
setError(null);
|
|
57
|
+
setEditing(true);
|
|
58
|
+
}, [slug, deviceName]);
|
|
59
|
+
|
|
60
|
+
const handleSave = useCallback(async () => {
|
|
61
|
+
const next = draft.trim().toLowerCase();
|
|
62
|
+
if (!SLUG_REGEX.test(next)) {
|
|
63
|
+
setError(SLUG_HINT);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setSaving(true);
|
|
67
|
+
setError(null);
|
|
68
|
+
try {
|
|
69
|
+
const res = await api.patch<{ slug: string }>("/api/cloud/alias", { slug: next });
|
|
70
|
+
setSlug(res.slug);
|
|
71
|
+
setEditing(false);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
setError(e instanceof Error ? e.message : "Could not save that name");
|
|
74
|
+
} finally {
|
|
75
|
+
setSaving(false);
|
|
76
|
+
}
|
|
77
|
+
}, [draft]);
|
|
78
|
+
|
|
79
|
+
if (loading) return null;
|
|
80
|
+
|
|
81
|
+
if (unreadable) {
|
|
82
|
+
return (
|
|
83
|
+
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
84
|
+
Couldn't check this machine's permanent link. Open the cloud dashboard, or sign in again.
|
|
85
|
+
</p>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (editing) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="space-y-1.5">
|
|
92
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Permanent link</span>
|
|
93
|
+
<div className="flex items-center gap-1">
|
|
94
|
+
<span className="text-xs font-mono text-muted-foreground shrink-0">{displayHost(cloudUrl)}/</span>
|
|
95
|
+
<input
|
|
96
|
+
autoFocus
|
|
97
|
+
value={draft}
|
|
98
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
99
|
+
onKeyDown={(e) => {
|
|
100
|
+
if (e.key === "Enter") handleSave();
|
|
101
|
+
if (e.key === "Escape") setEditing(false);
|
|
102
|
+
}}
|
|
103
|
+
placeholder="my-mac"
|
|
104
|
+
// 16px font on mobile — anything smaller makes iOS zoom on focus.
|
|
105
|
+
className="flex-1 min-w-0 text-base md:text-xs font-mono text-foreground bg-muted px-2 min-h-11 md:min-h-0 md:py-1.5 rounded border border-border outline-none focus:border-primary"
|
|
106
|
+
/>
|
|
107
|
+
<button
|
|
108
|
+
onClick={handleSave}
|
|
109
|
+
disabled={saving}
|
|
110
|
+
title="Save"
|
|
111
|
+
className="flex items-center justify-center size-11 md:size-8 rounded border border-border text-primary bg-muted hover:bg-accent transition-colors shrink-0 disabled:opacity-50"
|
|
112
|
+
>
|
|
113
|
+
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => setEditing(false)}
|
|
117
|
+
title="Cancel"
|
|
118
|
+
className="flex items-center justify-center size-11 md:size-8 rounded border border-border text-muted-foreground bg-muted hover:bg-accent transition-colors shrink-0"
|
|
119
|
+
>
|
|
120
|
+
<X className="size-3.5" />
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
<p className="text-[10px] text-muted-foreground">{error ?? SLUG_HINT}</p>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!slug) {
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
onClick={startEdit}
|
|
132
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 min-h-11 md:min-h-0 md:py-2 text-xs font-medium rounded-md border border-border hover:bg-muted transition-colors"
|
|
133
|
+
>
|
|
134
|
+
<Pencil className="size-3.5" />
|
|
135
|
+
Set a permanent link
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="space-y-1">
|
|
142
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Permanent link</span>
|
|
143
|
+
<div className="flex items-center gap-1">
|
|
144
|
+
<a
|
|
145
|
+
href={`${cloudUrl}/${slug}`}
|
|
146
|
+
target="_blank"
|
|
147
|
+
rel="noopener noreferrer"
|
|
148
|
+
className="flex-1 min-w-0 flex items-center gap-1 text-xs font-mono text-primary hover:underline truncate"
|
|
149
|
+
>
|
|
150
|
+
<span className="truncate">{displayHost(cloudUrl)}/{slug}</span>
|
|
151
|
+
<ExternalLink className="size-3 shrink-0" />
|
|
152
|
+
</a>
|
|
153
|
+
<button
|
|
154
|
+
onClick={startEdit}
|
|
155
|
+
title="Change permanent link"
|
|
156
|
+
className="flex items-center justify-center size-11 md:size-8 rounded border border-border text-muted-foreground bg-muted hover:bg-accent hover:text-foreground transition-colors shrink-0"
|
|
157
|
+
>
|
|
158
|
+
<Pencil className="size-3.5" />
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -2,6 +2,8 @@ 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";
|
|
6
|
+
import { CloudAliasRow } from "./cloud-alias-row";
|
|
5
7
|
|
|
6
8
|
interface CloudStatus {
|
|
7
9
|
logged_in: boolean;
|
|
@@ -54,7 +56,7 @@ export function CloudSharePopover({ onClose, variant = "popover" }: Props) {
|
|
|
54
56
|
}, []);
|
|
55
57
|
|
|
56
58
|
const handleCopy = useCallback((url: string) => {
|
|
57
|
-
|
|
59
|
+
void copyToClipboard(url);
|
|
58
60
|
setCopied(url);
|
|
59
61
|
setTimeout(() => setCopied(null), 2000);
|
|
60
62
|
}, []);
|
|
@@ -249,13 +251,21 @@ export function CloudSharePopover({ onClose, variant = "popover" }: Props) {
|
|
|
249
251
|
)}
|
|
250
252
|
</div>
|
|
251
253
|
) : (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
<>
|
|
255
|
+
{shareUrl && (
|
|
256
|
+
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
257
|
+
This link changes every time PPM restarts. Sign in to get a permanent one
|
|
258
|
+
that always points here — private to your account.
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
<button
|
|
262
|
+
onClick={handleCloudLogin}
|
|
263
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium rounded-md border border-border hover:bg-muted transition-colors"
|
|
264
|
+
>
|
|
265
|
+
<Cloud className="size-3.5" />
|
|
266
|
+
{shareUrl ? "Get a permanent link" : "Sign in to PPM Cloud"}
|
|
267
|
+
</button>
|
|
268
|
+
</>
|
|
259
269
|
)}
|
|
260
270
|
</div>
|
|
261
271
|
|
|
@@ -263,20 +273,23 @@ export function CloudSharePopover({ onClose, variant = "popover" }: Props) {
|
|
|
263
273
|
{cloud?.logged_in && (
|
|
264
274
|
<div className="space-y-1.5">
|
|
265
275
|
{cloud.linked ? (
|
|
266
|
-
|
|
267
|
-
<div className="flex items-center
|
|
268
|
-
<
|
|
269
|
-
|
|
276
|
+
<>
|
|
277
|
+
<div className="flex items-center justify-between text-xs">
|
|
278
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
279
|
+
<Link className="size-3 text-primary shrink-0" />
|
|
280
|
+
<span className="text-foreground truncate">{cloud.device_name}</span>
|
|
281
|
+
</div>
|
|
282
|
+
<button
|
|
283
|
+
onClick={handleUnlink}
|
|
284
|
+
disabled={unlinking}
|
|
285
|
+
className="text-text-subtle hover:text-destructive p-1 rounded hover:bg-muted transition-colors shrink-0"
|
|
286
|
+
title="Unlink device"
|
|
287
|
+
>
|
|
288
|
+
{unlinking ? <Loader2 className="size-3 animate-spin" /> : <Unlink className="size-3" />}
|
|
289
|
+
</button>
|
|
270
290
|
</div>
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
disabled={unlinking}
|
|
274
|
-
className="text-text-subtle hover:text-destructive p-1 rounded hover:bg-muted transition-colors shrink-0"
|
|
275
|
-
title="Unlink device"
|
|
276
|
-
>
|
|
277
|
-
{unlinking ? <Loader2 className="size-3 animate-spin" /> : <Unlink className="size-3" />}
|
|
278
|
-
</button>
|
|
279
|
-
</div>
|
|
291
|
+
<CloudAliasRow cloudUrl={cloud.cloud_url} deviceName={cloud.device_name} />
|
|
292
|
+
</>
|
|
280
293
|
) : (
|
|
281
294
|
<button
|
|
282
295
|
onClick={handleLink}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
RefreshCw,
|
|
18
18
|
Plus,
|
|
19
19
|
Columns2,
|
|
20
|
+
Cloud,
|
|
20
21
|
} from "lucide-react";
|
|
21
22
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
22
23
|
import { useProjectStore } from "@/stores/project-store";
|
|
@@ -193,6 +194,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
193
194
|
{ id: "new-db-query", label: "New DB Query", icon: Database, action: () => { useTabStore.getState().openNewFile({ language: "sql", title: "SQL Query" }); onClose(); }, keywords: "sql database query scratchpad new", group: "action" },
|
|
194
195
|
{ id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action", shortcut: formatShortcut(getBinding("open-terminal")) },
|
|
195
196
|
{ id: "tunnels", label: "Cloudflare Tunnels", icon: Globe, action: () => { if (sidebarCollapsed) toggleSidebar(); setSidebarActiveTab("tunnels"); onClose(); }, keywords: "web preview localhost port forward tunnel cloudflare url", group: "action" },
|
|
197
|
+
{ id: "cloud-share", label: "PPM Cloud & Share", icon: Cloud, action: () => { window.dispatchEvent(new CustomEvent("open-cloud-share")); onClose(); }, keywords: "cloud permanent link alias share phone remote device qr sign in login", group: "action" },
|
|
196
198
|
{ id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
|
|
197
199
|
{ id: "voice-input", label: "Voice Input", icon: Mic, action: () => { window.dispatchEvent(new CustomEvent("toggle-voice-input")); onClose(); }, keywords: "speech microphone dictate voice", group: "action", shortcut: formatShortcut(getBinding("voice-input")) },
|
|
198
200
|
{ id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action", shortcut: formatShortcut(getBinding("open-git-status")) },
|
|
@@ -21,6 +21,7 @@ import { openBugReportPopup } from "@/lib/report-bug";
|
|
|
21
21
|
import { UpgradeButton } from "@/components/layout/upgrade-button";
|
|
22
22
|
import { CloudSharePopover } from "@/components/layout/cloud-share-popover";
|
|
23
23
|
import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
|
|
24
|
+
import { isMobileDevice } from "@/hooks/use-is-mobile";
|
|
24
25
|
import { cn } from "@/lib/utils";
|
|
25
26
|
|
|
26
27
|
// Tab ids the mobile drawer can render content for. `search` is desktop-only for now;
|
|
@@ -62,6 +63,15 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
|
|
|
62
63
|
if (tabs.length > 0 && !tabs.some((t) => t.id === activeTab)) setActiveTab(tabs[0]!.id);
|
|
63
64
|
}, [tabs, activeTab]);
|
|
64
65
|
|
|
66
|
+
// Command palette request — the desktop rail is hidden on mobile, so the
|
|
67
|
+
// sheet here is what opens. The sheet portals to body, so it shows even while
|
|
68
|
+
// the drawer itself is closed.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const open = () => { if (isMobileDevice()) setCloudOpen(true); };
|
|
71
|
+
window.addEventListener("open-cloud-share", open);
|
|
72
|
+
return () => window.removeEventListener("open-cloud-share", open);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
65
75
|
const handleReportBug = useCallback(() => openBugReportPopup(version), [version]);
|
|
66
76
|
|
|
67
77
|
const noProject = (
|
|
@@ -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)
|
|
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
|
-
|
|
197
|
+
void copyToClipboard(project ? `${project.path}/${filePath}` : filePath);
|
|
197
198
|
}
|
|
198
199
|
break;
|
|
199
200
|
}
|
|
@@ -13,6 +13,7 @@ import { useNotificationStore, selectProjectUnread } from "@/stores/notification
|
|
|
13
13
|
import { NotificationBellPopover } from "./notification-bell-popover";
|
|
14
14
|
import { CloudSharePopover } from "./cloud-share-popover";
|
|
15
15
|
import { openBugReportPopup } from "@/lib/report-bug";
|
|
16
|
+
import { isMobileDevice } from "@/hooks/use-is-mobile";
|
|
16
17
|
import { cn } from "@/lib/utils";
|
|
17
18
|
|
|
18
19
|
function Badge({ count }: { count: number }) {
|
|
@@ -135,6 +136,14 @@ export const NavSectionRail = memo(function NavSectionRail({ className }: { clas
|
|
|
135
136
|
setPopoverPos({ left: rect.right + 6, bottom: window.innerHeight - rect.bottom });
|
|
136
137
|
}, [cloudOpen]);
|
|
137
138
|
|
|
139
|
+
// Command palette can't reach the popover's local state, so it asks via event.
|
|
140
|
+
// The rail is `hidden md:flex`, so on mobile the drawer's bottom sheet answers.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const open = () => { if (!isMobileDevice()) setCloudOpen(true); };
|
|
143
|
+
window.addEventListener("open-cloud-share", open);
|
|
144
|
+
return () => window.removeEventListener("open-cloud-share", open);
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
138
147
|
const handleReportBug = () => openBugReportPopup(version);
|
|
139
148
|
|
|
140
149
|
// Rail tab click: collapsed → open on any tab; open → clicking the active tab closes it.
|
|
@@ -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)
|
|
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={() =>
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
await navigator.clipboard.writeText(text);
|
|
351
|
+
if (await copyToClipboard(text)) {
|
|
352
352
|
showMessage({ type: "success", text: "Backup copied to clipboard!" });
|
|
353
|
-
}
|
|
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);
|