Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ Thumbs.db

# PR descriptions generated by the create-pr skill
/.pr
/graphify-out
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 27 additions & 11 deletions scripts/verify-multistack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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");

Expand All @@ -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");
Expand All @@ -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())
Expand Down
29 changes: 29 additions & 0 deletions src/components/SecretValue.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className="text-zinc-500">—</span>;
Comment on lines +1 to +13

return (
<span className="flex items-center gap-2">
<span className={shown ? "" : "tracking-widest"}>{shown ? value : "••••••••"}</span>
<button
onClick={() => setShown((s) => !s)}
title={shown ? "Hide" : "Reveal"}
aria-label={shown ? "Hide value" : "Reveal value"}
className="text-zinc-500 transition-colors hover:text-zinc-200"
>
{shown ? <EyeOffIcon className="h-3.5 w-3.5" /> : <EyeIcon className="h-3.5 w-3.5" />}
</button>
<CopyButton value={value} />
</span>
);
}
6 changes: 6 additions & 0 deletions src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
ChevronsRight,
Copy,
Database,
Eye,
EyeOff,
FileText,
FolderOpen,
Globe,
House,
Keyboard,
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/mock/opener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
export async function openUrl(_url: string, _openWith?: string): Promise<void> {
// no-op
}

export async function revealItemInDir(_path: string): Promise<void> {
// no-op
}
27 changes: 21 additions & 6 deletions src/pages/SiteDetail.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -306,6 +308,14 @@ export default function SiteDetail({ id, tab: navTab }: { id: string; tab?: Site
<ArrowUpRightIcon className="h-3.5 w-3.5" />
Open site
</button>
<button
onClick={() => void revealItemInDir(detail.path)}
title={`Reveal the site folder (${detail.path}) — wp-content is live-mounted, edits apply instantly`}
className="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:border-zinc-500"
>
<FolderIcon className="h-3.5 w-3.5" />
Open folder
</button>
{caps.one_click_login && (
<button
onClick={() => void wpAdminLogin()}
Expand All @@ -316,7 +326,7 @@ export default function SiteDetail({ id, tab: navTab }: { id: string; tab?: Site
{loggingIn ? "Logging in…" : "WP Admin"}
</button>
)}
{caps.one_click_login && running && wpUsers && wpUsers.length > 1 && (
{caps.one_click_login && running && wpUsers && wpUsers.length > 0 && (
<select
value={selectedUserId ?? ""}
onChange={(e) => setLoginUserId(Number(e.target.value))}
Expand Down Expand Up @@ -347,8 +357,8 @@ export default function SiteDetail({ id, tab: navTab }: { id: string; tab?: Site
</div>
<div className="flex items-center justify-between gap-2">
<dt className="text-zinc-500">Password</dt>
<dd className="flex items-center gap-2 font-mono text-zinc-200">
{detail.admin_pass || "—"} {detail.admin_pass && <CopyButton value={detail.admin_pass} />}
<dd className="font-mono text-zinc-200">
<SecretValue value={detail.admin_pass} />
</dd>
</div>
</dl>
Expand All @@ -365,15 +375,20 @@ export default function SiteDetail({ id, tab: navTab }: { id: string; tab?: Site
["Port", String(detail.db_port)],
["Database", detail.db_name],
["User", detail.db_user],
["Password", detail.db_password || "—"],
].map(([k, v]) => (
<div key={k} className="flex items-center justify-between gap-2">
<dt className="text-zinc-500">{k}</dt>
<dd className="flex items-center gap-2 font-mono text-zinc-200">
{v} {v !== "—" && <CopyButton value={v} />}
{v} <CopyButton value={v} />
</dd>
</div>
))}
<div className="flex items-center justify-between gap-2">
<dt className="text-zinc-500">Password</dt>
<dd className="font-mono text-zinc-200">
<SecretValue value={detail.db_password} />
</dd>
</div>
</dl>
</section>
)}
Expand Down
Loading