From baa87c975a761982a36175825a4549fe84d8ff68 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Wed, 22 Jul 2026 17:48:39 -0400 Subject: [PATCH 1/2] Ignore graphify output directory Add `/graphify-out` to `.gitignore` so generated Graphify output is not tracked in git. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c7a4dfb..fbb887d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ Thumbs.db # PR descriptions generated by the create-pr skill /.pr +/graphify-out From 924b434480147f0ca20531969196245c0b33323e Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Thu, 30 Jul 2026 00:47:58 -0400 Subject: [PATCH 2/2] Mask secrets and update multistack verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reusable `SecretValue` component (masked-by-default values with reveal/copy) and wires it into SiteDetail for admin/database passwords. SiteDetail also gets an “Open folder” action via `revealItemInDir`, updates login-user selection behavior, and includes supporting icon/mock-opener updates. The multistack verification script was adjusted for the plan-28 UI changes (icon-only actions, Home→Browse Sites flow, logs tab, and new kind indicators), and AGENTS.md now documents the new SecretValue component. --- AGENTS.md | 3 ++- scripts/verify-multistack.mjs | 38 ++++++++++++++++++++++++---------- src/components/SecretValue.tsx | 29 ++++++++++++++++++++++++++ src/components/icons.tsx | 6 ++++++ src/mock/opener.ts | 4 ++++ src/pages/SiteDetail.tsx | 27 ++++++++++++++++++------ 6 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 src/components/SecretValue.tsx diff --git a/AGENTS.md b/AGENTS.md index 51e6679..874ce28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,8 @@ src/ React 18 + TS + Vite frontend Terminal (one tab per site, shell in the wordpress container), Settings (modal, opened via sidebar gear — not a page) - components/ Sidebar, StatusBadge, CopyButton, NewSiteDialog, + components/ Sidebar, StatusBadge, CopyButton, SecretValue + (masked password + eye toggle), NewSiteDialog, CommandPalette, KeyboardSettings, KeyboardShortcutsDialog, SnapshotsPanel, DeleteSiteDialog, ImportSiteDialog, CloneSiteDialog, diff --git a/scripts/verify-multistack.mjs b/scripts/verify-multistack.mjs index a54161e..2a9d743 100644 --- a/scripts/verify-multistack.mjs +++ b/scripts/verify-multistack.mjs @@ -105,12 +105,14 @@ async function main() { ); // The specific card for `siteName`: climb from its exact-match title button // to the nearest ancestor that owns a Details button (the card itself, not - // the whole grid — which would match every card's buttons at once). + // the whole grid — which would match every card's buttons at once). Card + // actions are icon-only since plan 28, so match on aria-label too. const findCard = `(n) => { + const label = (b) => b.textContent.trim() || b.getAttribute('aria-label') || ''; const title = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === n); if (!title) return null; let card = title.parentElement; - while (card && ![...card.querySelectorAll(':scope button')].some((b) => b.textContent.trim() === 'Details')) { + while (card && ![...card.querySelectorAll(':scope button')].some((b) => label(b) === 'Details')) { card = card.parentElement; } return card; @@ -119,7 +121,8 @@ async function main() { page.evaluate( (n, find) => { const card = new Function('return ' + find)()(n); - return card ? [...card.querySelectorAll("button")].map((b) => b.textContent.trim()) : null; + const label = (b) => b.textContent.trim() || b.getAttribute('aria-label') || ''; + return card ? [...card.querySelectorAll("button")].map(label) : null; }, siteName, findCard @@ -128,12 +131,19 @@ async function main() { page.evaluate( (n, find) => { const card = new Function('return ' + find)()(n); - [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click(); + const label = (b) => b.textContent.trim() || b.getAttribute('aria-label') || ''; + [...card.querySelectorAll("button")].find((b) => label(b) === "Details").click(); }, siteName, findCard ); + // Since plan 28 the app lands on a Home overview — go to the Sites grid + // first (the sidebar alone already lists every site name, so the wait + // below can't tell the pages apart). + await clickByText("button", "Browse sites"); + await sleep(600); + await page.waitForFunction(() => document.body.innerText.includes("Analytics API")); console.log("› dashboard loaded"); @@ -144,13 +154,12 @@ async function main() { ok("wordpress card renders", wpBtns !== null); ok("docker card has no Clone", dockerBtns && !dockerBtns.includes("Clone")); ok("wordpress card has a Clone", wpBtns && wpBtns.includes("Clone")); - const badges = await page.evaluate(() => - [...document.querySelectorAll("span")] - .map((s) => s.textContent.trim()) - .filter((t) => t === "WP" || t === "Docker") - ); - ok("a Docker kind badge is shown", badges.includes("Docker")); - ok("a WP kind badge is shown", badges.includes("WP")); + // Plan 28 replaced the text kind badges with brand-icon tiles; the card's + // stack label line is the remaining kind signal ("Docker · api" / + // "WP 6.x · PHP 8.x"). + const dashText = await bodyText(); + ok("a Docker kind badge is shown", /Docker · /.test(dashText)); + ok("a WP kind badge is shown", /WP \d/.test(dashText)); // 2) Docker SiteDetail: WP-only sections are gone, generic ones remain. await openDetail("Analytics API"); @@ -162,7 +171,14 @@ async function main() { ok("docker detail hides the database panel", !/Database \(MariaDB\)/i.test(text)); ok("docker detail hides wp-cli info", !/WordPress info/i.test(text)); ok("docker detail keeps the Snapshots panel", /snapshots/i.test(text)); + // Logs are their own tab since plan 28 — visit it, then come back. + await clickByText("button", "Logs"); + await sleep(400); + text = await bodyText(); ok("docker detail keeps Container logs", /Container logs/i.test(text)); + await clickByText("button", "Overview"); + await sleep(400); + text = await bodyText(); ok("docker detail shows the app service", /app service/i.test(text)); const detailButtons = await page.evaluate(() => [...document.querySelectorAll("button")].map((b) => b.textContent.trim()) diff --git a/src/components/SecretValue.tsx b/src/components/SecretValue.tsx new file mode 100644 index 0000000..3b8c7fc --- /dev/null +++ b/src/components/SecretValue.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import CopyButton from "./CopyButton"; +import { EyeIcon, EyeOffIcon } from "./icons"; + +/** + * A password-style value: masked with bullets by default, an eye toggle to + * reveal it, and a Copy button that always copies the real value (even while + * masked). Empty values render as "—" with no affordances. + */ +export default function SecretValue({ value }: { value: string }) { + const [shown, setShown] = useState(false); + + if (!value) return ; + + return ( + + {shown ? value : "••••••••"} + + + + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index eac5a1a..acd76b5 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -14,7 +14,10 @@ import { ChevronsRight, Copy, Database, + Eye, + EyeOff, FileText, + FolderOpen, Globe, House, Keyboard, @@ -68,6 +71,9 @@ export const DuplicateIcon = wrap(Copy); export const TrashIcon = wrap(Trash2); export const BookmarkIcon = wrap(Bookmark); export const CheckIcon = wrap(Check); +export const FolderIcon = wrap(FolderOpen); +export const EyeIcon = wrap(Eye); +export const EyeOffIcon = wrap(EyeOff); // Navigation + section headers (plans 27/28) export const HomeIcon = wrap(House); diff --git a/src/mock/opener.ts b/src/mock/opener.ts index 5f5e45f..a4e78ae 100644 --- a/src/mock/opener.ts +++ b/src/mock/opener.ts @@ -4,3 +4,7 @@ export async function openUrl(_url: string, _openWith?: string): Promise { // no-op } + +export async function revealItemInDir(_path: string): Promise { + // no-op +} diff --git a/src/pages/SiteDetail.tsx b/src/pages/SiteDetail.tsx index 60c6e51..155ff27 100644 --- a/src/pages/SiteDetail.tsx +++ b/src/pages/SiteDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { openUrl } from "@tauri-apps/plugin-opener"; +import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { ipc } from "../lib/ipc"; import { siteUrl, sitePort } from "../lib/domains"; import { useNav, type SiteTab } from "../stores/nav"; @@ -11,12 +11,14 @@ import KindBadge from "../components/KindBadge"; import SiteTile from "../components/SiteTile"; import SectionTitle from "../components/SectionTitle"; import CopyButton from "../components/CopyButton"; +import SecretValue from "../components/SecretValue"; import { ArrowUpRightIcon, BookmarkIcon, DatabaseIcon, DuplicateIcon, FileTextIcon, + FolderIcon, GlobeIcon, KeyIcon, PlayIcon, @@ -306,6 +308,14 @@ export default function SiteDetail({ id, tab: navTab }: { id: string; tab?: Site Open site + {caps.one_click_login && ( )} - {caps.one_click_login && running && wpUsers && wpUsers.length > 1 && ( + {caps.one_click_login && running && wpUsers && wpUsers.length > 0 && (