@hienlh/ppm 0.17.38 → 0.17.39

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 (49) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +5 -1
  4. package/dist/web/assets/{ai-resource-editor-ChT9FzxT.js → ai-resource-editor-CAQ_0AXP.js} +1 -1
  5. package/dist/web/assets/{audio-preview-CSK6nxNy.js → audio-preview-BUBpG5oQ.js} +1 -1
  6. package/dist/web/assets/{chat-tab-DGM7js5J.js → chat-tab-BhjsCgzO.js} +3 -3
  7. package/dist/web/assets/{code-editor-BvLxm1bf.js → code-editor-CO_084SG.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-Bc063EfD.js → conflict-editor-BsWmVy7f.js} +1 -1
  9. package/dist/web/assets/{csv-preview-ft27dubA.js → csv-preview-CfwxBxoL.js} +1 -1
  10. package/dist/web/assets/{database-viewer-Bdj1ferk.js → database-viewer-iR997ZF8.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-C46_ZovZ.js → diff-viewer-DyOLq66H.js} +1 -1
  12. package/dist/web/assets/{docx-preview-CLDLTD8N.js → docx-preview-CwjJkYcL.js} +1 -1
  13. package/dist/web/assets/{extension-webview-BSvKnN1E.js → extension-webview-MI4F6BtU.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-GP8XaTlA.js → git-log-panel-CYwATO2N.js} +1 -1
  15. package/dist/web/assets/{glide-data-grid-On_pS9Y7.js → glide-data-grid-B3DyD4fp.js} +1 -1
  16. package/dist/web/assets/{group-chat-tab-DmYt9GfT.js → group-chat-tab-CgWgatpe.js} +1 -1
  17. package/dist/web/assets/{image-preview-BWNTrufr.js → image-preview-CcPAUgwV.js} +1 -1
  18. package/dist/web/assets/index-BEB87kj8.css +2 -0
  19. package/dist/web/assets/index-CSpawh6a.js +50 -0
  20. package/dist/web/assets/{keybindings-store-B4EWnplE.js → keybindings-store-CT2XJy_J.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-suZuQvya.js → markdown-renderer-B54mHbyz.js} +1 -1
  22. package/dist/web/assets/{markdown-renderer-Bhv5w9Op.js → markdown-renderer-UboKVdKw.js} +1 -1
  23. package/dist/web/assets/{notification-store-B3zEJT3q.js → notification-store-Cw-106GW.js} +1 -1
  24. package/dist/web/assets/{pdf-preview-DcP6Tj7N.js → pdf-preview-B9munmj0.js} +1 -1
  25. package/dist/web/assets/{postgres-viewer-BOlXoo4R.js → postgres-viewer-DMBDGrnZ.js} +1 -1
  26. package/dist/web/assets/{settings-tab-BqJJqL1l.js → settings-tab-CXqpAnZU.js} +1 -1
  27. package/dist/web/assets/{sql-query-editor-h4zuID_d.js → sql-query-editor-BT11M9Xs.js} +1 -1
  28. package/dist/web/assets/{sqlite-viewer-D5X8iWUw.js → sqlite-viewer-BMrS0LPb.js} +1 -1
  29. package/dist/web/assets/{system-monitor-tab-DL-viZmD.js → system-monitor-tab-CznKv8Bb.js} +1 -1
  30. package/dist/web/assets/terminal-tab-DLvj76hN.js +3 -0
  31. package/dist/web/assets/{tool-cards-zK7OP3DS.js → tool-cards-BgUD_NIq.js} +2 -2
  32. package/dist/web/assets/{use-monaco-theme-Bk-dETGV.js → use-monaco-theme-BMgGJW1N.js} +1 -1
  33. package/dist/web/assets/{video-preview-C6bKCBvz.js → video-preview-Bkre8g0Y.js} +1 -1
  34. package/dist/web/index.html +2 -2
  35. package/dist/web/sw.js +1 -1
  36. package/package.json +1 -1
  37. package/src/server/index.ts +4 -0
  38. package/src/server/routes/oauth-loopback.ts +75 -0
  39. package/src/web/components/layout/dock-panel.tsx +8 -1
  40. package/src/web/components/layout/mobile-nav.tsx +16 -2
  41. package/src/web/components/terminal/terminal-finish-login.tsx +75 -0
  42. package/src/web/components/terminal/terminal-links-sheet.tsx +53 -30
  43. package/src/web/components/ui/mobile-bottom-sheet.tsx +29 -26
  44. package/src/web/hooks/use-terminal.ts +18 -7
  45. package/src/web/hooks/use-visual-viewport.ts +83 -0
  46. package/src/web/lib/oauth-loopback-url.ts +55 -0
  47. package/dist/web/assets/index-Crnizc26.js +0 -50
  48. package/dist/web/assets/index-opWDpE6A.css +0 -2
  49. package/dist/web/assets/terminal-tab-BVW9MfC3.js +0 -3
@@ -6,10 +6,11 @@
6
6
  * Also exports context-menu-specific sub-components (BottomSheetItem, etc.)
7
7
  * used by adaptive-context-menu.tsx.
8
8
  */
9
- import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
9
+ import { createContext, useContext, type ReactNode } from "react";
10
10
  import { createPortal } from "react-dom";
11
11
  import { cn } from "@/lib/utils";
12
12
  import { useSwipeToDismiss } from "@/hooks/use-swipe-to-dismiss";
13
+ import { useVisualViewport } from "@/hooks/use-visual-viewport";
13
14
 
14
15
  /* ------------------------------------------------------------------ */
15
16
  /* Core BottomSheet — reusable everywhere */
@@ -33,44 +34,46 @@ export function BottomSheet({ open, onClose, children, className, zIndex = 50 }:
33
34
  const { dragY, swipeHandlers, dragStyle, backdropOpacity, isDragging } =
34
35
  useSwipeToDismiss(onClose);
35
36
 
36
- // Follow the visual viewport so the panel rides above the on-screen keyboard.
37
- // A `fixed inset-0` container is anchored to the layout viewport, which does
38
- // not shrink when the keyboard opens so `bottom-0` would sit behind it.
39
- // Constraining the container to the visual viewport keeps the panel visible.
40
- const [vv, setVv] = useState<{ top: number; height: number } | null>(null);
41
- useEffect(() => {
42
- const viewport = window.visualViewport;
43
- if (!open || !viewport) return;
44
- const update = () => setVv({ top: viewport.offsetTop, height: viewport.height });
45
- update();
46
- viewport.addEventListener("resize", update);
47
- viewport.addEventListener("scroll", update);
48
- return () => {
49
- viewport.removeEventListener("resize", update);
50
- viewport.removeEventListener("scroll", update);
51
- };
52
- }, [open]);
37
+ // Keyboard-aware geometry. The backdrop deliberately keeps covering the whole
38
+ // layout viewport while only the panel tracks the keyboard: if the two ever
39
+ // disagree by a few pixels, the seam shows as dimmed backdrop rather than a
40
+ // strip of untouched page.
41
+ const insets = useVisualViewport(open);
53
42
 
54
43
  if (!open) return null;
55
44
 
56
- // When tracking the visual viewport, pin the container to it (top + height,
57
- // bottom auto) instead of the full-page inset-0 default.
58
- const containerStyle = vv
59
- ? { zIndex, top: `${vv.top}px`, height: `${vv.height}px`, bottom: "auto" as const }
60
- : { zIndex };
45
+ // The panel keeps its footing on the bottom of the screen and only its
46
+ // *contents* are lifted clear of the keyboard, by padding. Moving the panel
47
+ // itself is what left a hole: the keyboard's height can only be estimated, and
48
+ // an estimate that is short drags the panel's background up with it, exposing
49
+ // the page. Padding an anchored panel degrades instead — a bad estimate puts
50
+ // the toolbar slightly off, over a background that still reaches the bottom.
51
+ //
52
+ // `--sheet-vh` is what is visible and `--sheet-kb` what the keyboard covers,
53
+ // so a panel can ask for "the visible area" and still span to the screen edge.
54
+ const frameStyle = {
55
+ zIndex,
56
+ "--sheet-vh": insets ? `${insets.height}px` : "100dvh",
57
+ "--sheet-kb": insets ? `${insets.keyboardInset}px` : "0px",
58
+ } as React.CSSProperties;
61
59
 
62
60
  return createPortal(
63
- <div className="fixed inset-0" style={containerStyle} onClick={onClose}>
61
+ <div className="fixed inset-0" style={frameStyle} onClick={onClose}>
64
62
  {/* Backdrop */}
65
63
  <div
66
64
  className="absolute inset-0 bg-black/40 animate-in fade-in-0 duration-200"
67
65
  style={isDragging ? { opacity: backdropOpacity } : undefined}
68
66
  />
69
- {/* Panel */}
67
+ {/* Panel. Padding does double duty: it clears the home indicator when the
68
+ keyboard is down, and the keyboard itself when it is up. A panel sized
69
+ in `--sheet-vh` must add `--sheet-kb` back, since the padding is inside
70
+ that height. */}
70
71
  <div
71
72
  className={cn(
72
73
  "absolute bottom-0 left-0 right-0 rounded-t-2xl bg-popover text-popover-foreground border-t border-border",
73
- "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
74
+ insets?.keyboardOpen
75
+ ? "pb-[var(--sheet-kb,0px)]"
76
+ : "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
74
77
  !isDragging && "animate-in slide-in-from-bottom duration-200",
75
78
  className,
76
79
  )}
@@ -44,6 +44,8 @@ const URL_TRAILING_PUNCT = /[).,;:'"\]}>]+$/;
44
44
  const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/g;
45
45
  /** Cap the scan so a 50k-line scrollback never blocks the tap that triggered it. */
46
46
  const URL_SCAN_MAX_ROWS = 5000;
47
+ /** Quiet period before refitting to a new container size, in ms. */
48
+ const RESIZE_SETTLE_MS = 100;
47
49
 
48
50
  const RESIZE_PREFIX = "\x01RESIZE:";
49
51
  const PING_MSG = "\x01PING";
@@ -363,16 +365,24 @@ export function useTerminal(
363
365
  };
364
366
  document.addEventListener("visibilitychange", onVisibility);
365
367
 
366
- // ResizeObserver for auto-fit — skip when tab is hidden (0 dimensions)
368
+ // ResizeObserver for auto-fit — skip when tab is hidden (0 dimensions).
369
+ // Debounced: an on-screen keyboard resizes the container many times as it
370
+ // slides, and refitting on each one sends the shell a burst of size changes
371
+ // that it answers by redrawing, so the output visibly churns. One fit once
372
+ // the size settles gives the same result without the churn.
373
+ let fitTimer: ReturnType<typeof setTimeout> | null = null;
367
374
  const resizeObserver = new ResizeObserver((entries) => {
368
375
  const entry = entries[0];
369
376
  if (!entry || entry.contentRect.width === 0 || entry.contentRect.height === 0) return;
370
- try {
371
- fitAddon.fit();
372
- sendResize();
373
- } catch {
374
- // Ignore fit errors during teardown
375
- }
377
+ if (fitTimer) clearTimeout(fitTimer);
378
+ fitTimer = setTimeout(() => {
379
+ try {
380
+ fitAddon.fit();
381
+ sendResize();
382
+ } catch {
383
+ // Ignore fit errors during teardown
384
+ }
385
+ }, RESIZE_SETTLE_MS);
376
386
  });
377
387
  resizeObserver.observe(container);
378
388
 
@@ -384,6 +394,7 @@ export function useTerminal(
384
394
 
385
395
  return () => {
386
396
  unsubTheme();
397
+ if (fitTimer) clearTimeout(fitTimer);
387
398
  resizeObserver.disconnect();
388
399
  clearInterval(heartbeatInterval);
389
400
  document.removeEventListener("visibilitychange", onVisibility);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Measures the part of the screen the user can actually see, and how much of it
3
+ * the on-screen keyboard is occupying.
4
+ *
5
+ * `vh`/`dvh` units and a `fixed` element both resolve against the layout
6
+ * viewport, which does not shrink when a keyboard opens — so anything anchored
7
+ * to the bottom ends up behind it. Only `visualViewport` reports the truth.
8
+ *
9
+ * Updates are applied on the frame they arrive rather than after a settle
10
+ * delay: iOS emits intermediate sizes throughout the keyboard's slide and its
11
+ * final event can land before the layout has finished moving, so deferring is
12
+ * what leaves geometry stale — visibly, as a strip of untouched page below a
13
+ * panel that should be flush with the keyboard.
14
+ */
15
+ import { useEffect, useState } from "react";
16
+
17
+ export interface ViewportInsets {
18
+ /** Height of the visible area, in px. */
19
+ height: number;
20
+ /**
21
+ * Roughly how much the keyboard covers, in px.
22
+ *
23
+ * Approximate on purpose: `innerHeight` on iOS counts space behind browser
24
+ * chrome, so this can be off by a bar's height. Use it to inset content, never
25
+ * to place a background — an inset that is slightly wrong shifts content a
26
+ * little, whereas a background placed the same way leaves a visible hole.
27
+ */
28
+ keyboardInset: number;
29
+ /** Whether the missing height is big enough to be a keyboard, not browser chrome. */
30
+ keyboardOpen: boolean;
31
+ }
32
+
33
+ /** Below this, the missing height is a collapsed address bar, not a keyboard. */
34
+ const KEYBOARD_MIN_INSET = 120;
35
+
36
+ function measure(viewport: VisualViewport): ViewportInsets {
37
+ const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
38
+ return {
39
+ height: viewport.height,
40
+ keyboardInset: inset,
41
+ keyboardOpen: inset >= KEYBOARD_MIN_INSET,
42
+ };
43
+ }
44
+
45
+ /** Live viewport insets, or null when unsupported or while `active` is false. */
46
+ export function useVisualViewport(active: boolean): ViewportInsets | null {
47
+ const [insets, setInsets] = useState<ViewportInsets | null>(null);
48
+
49
+ useEffect(() => {
50
+ const viewport = window.visualViewport;
51
+ if (!active || !viewport) {
52
+ setInsets(null);
53
+ return;
54
+ }
55
+
56
+ // Several resize/scroll events can fire per frame; one read per frame is
57
+ // enough and keeps React from rendering the same geometry repeatedly.
58
+ let frame: number | null = null;
59
+ const update = () => {
60
+ frame = null;
61
+ setInsets((prev) => {
62
+ const next = measure(viewport);
63
+ return prev && prev.height === next.height && prev.keyboardInset === next.keyboardInset
64
+ ? prev
65
+ : next;
66
+ });
67
+ };
68
+ const schedule = () => {
69
+ if (frame === null) frame = requestAnimationFrame(update);
70
+ };
71
+
72
+ update();
73
+ viewport.addEventListener("resize", schedule);
74
+ viewport.addEventListener("scroll", schedule);
75
+ return () => {
76
+ if (frame !== null) cancelAnimationFrame(frame);
77
+ viewport.removeEventListener("resize", schedule);
78
+ viewport.removeEventListener("scroll", schedule);
79
+ };
80
+ }, [active]);
81
+
82
+ return insets;
83
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Recognises OAuth authorization URLs that hand their result back to a port on
3
+ * the machine running the CLI, rather than to whatever device opened them.
4
+ *
5
+ * This is what strands a login started from a phone: the sign-in succeeds, the
6
+ * browser is redirected to 127.0.0.1:<port>, and on the phone that address is
7
+ * the phone itself — so the CLI waiting on the real machine never hears back.
8
+ */
9
+
10
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1"]);
11
+
12
+ /** Whether a hostname addresses the machine the browser is running on. */
13
+ export function isLoopbackHostname(hostname: string): boolean {
14
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
15
+ if (LOOPBACK_HOSTNAMES.has(host)) return true;
16
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
17
+ }
18
+
19
+ /**
20
+ * The loopback port an authorization URL will redirect back to, or null when it
21
+ * redirects somewhere reachable from any device.
22
+ */
23
+ export function loopbackRedirectPort(url: string): string | null {
24
+ let parsed: URL;
25
+ try {
26
+ parsed = new URL(url);
27
+ } catch {
28
+ return null;
29
+ }
30
+ // `redirect_uri` is the OAuth 2.0 name; some tools still send `redirectUri`.
31
+ const redirect =
32
+ parsed.searchParams.get("redirect_uri") ?? parsed.searchParams.get("redirectUri");
33
+ if (!redirect) return null;
34
+
35
+ try {
36
+ const target = new URL(redirect);
37
+ if (!isLoopbackHostname(target.hostname)) return null;
38
+ return target.port || (target.protocol === "https:" ? "443" : "80");
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Whether a pasted string is a callback this machine could deliver. */
45
+ export function isDeliverableCallback(url: string): boolean {
46
+ try {
47
+ const parsed = new URL(url.trim());
48
+ return (
49
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
50
+ isLoopbackHostname(parsed.hostname)
51
+ );
52
+ } catch {
53
+ return false;
54
+ }
55
+ }