mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +02:00
Add route for command results in popup window (#93)
* feat: Add pop-out command route for standalone window rendering Add a new /run route that allows windows to be opened in separate browser windows/tabs without affecting the main workspace layout. Changes: - Add RunCommandPage component for /run?cmd=<command> route - Add Pop Out button to WindowToolbar (ExternalLink icon) - Parse command from URL query parameter and render result - Construct minimal WindowInstance for rendering - Display command string in header with clean minimal UI This enables users to pop out any window into a separate browser context while maintaining the main workspace layout, useful for multi-monitor setups or keeping reference windows visible. * refactor: Remove header from pop-out command page Simplify RunCommandPage to only show the window renderer without any additional UI chrome. This provides a cleaner, more focused experience for popped-out windows. * refactor: Move pop-out action to window menu dropdown Move the pop-out button from a standalone icon to the three-dot menu dropdown to reduce toolbar clutter. The menu now always appears since pop-out is always available. * feat: Add AppShell header to pop-out command page Wrap RunCommandPage with AppShell (hideBottomBar) to show the header with user menu and command launcher, matching the behavior of NIP-19 preview pages. When a command is launched from the /run page, it navigates to the main dashboard (/) where the window system exists. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,11 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|||||||
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
||||||
import "./command-launcher.css";
|
import "./command-launcher.css";
|
||||||
|
|
||||||
/** Check if current path is a NIP-19 preview route (no window system) */
|
/** Check if current path doesn't have the window system (should navigate to / when launching commands) */
|
||||||
function isNip19PreviewRoute(pathname: string): boolean {
|
function isNonDashboardRoute(pathname: string): boolean {
|
||||||
|
// /run route - pop-out command page
|
||||||
|
if (pathname.startsWith("/run")) return true;
|
||||||
|
|
||||||
// NIP-19 preview routes are single-segment paths starting with npub1, note1, nevent1, naddr1
|
// NIP-19 preview routes are single-segment paths starting with npub1, note1, nevent1, naddr1
|
||||||
const segment = pathname.slice(1); // Remove leading /
|
const segment = pathname.slice(1); // Remove leading /
|
||||||
if (segment.includes("/")) return false; // Multi-segment paths are not NIP-19 previews
|
if (segment.includes("/")) return false; // Multi-segment paths are not NIP-19 previews
|
||||||
@@ -123,9 +126,9 @@ export default function CommandLauncher({
|
|||||||
});
|
});
|
||||||
setEditMode(null); // Clear edit mode
|
setEditMode(null); // Clear edit mode
|
||||||
} else {
|
} else {
|
||||||
// If on a NIP-19 preview route (no window system), navigate to dashboard first
|
// If on a non-dashboard route (no window system), navigate to dashboard first
|
||||||
// The window will appear after navigation since state persists
|
// The window will appear after navigation since state persists
|
||||||
if (isNip19PreviewRoute(location.pathname)) {
|
if (isNonDashboardRoute(location.pathname)) {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
CopyCheck,
|
CopyCheck,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -91,6 +92,21 @@ export function WindowToolbar({
|
|||||||
setShowSpellDialog(true);
|
setShowSpellDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePopOut = () => {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
// Get command string (existing or reconstructed)
|
||||||
|
const commandString = window.commandString || reconstructCommand(window);
|
||||||
|
|
||||||
|
// Construct the /run URL with the command as a query parameter
|
||||||
|
const popOutUrl = `/run?cmd=${encodeURIComponent(commandString)}`;
|
||||||
|
|
||||||
|
// Open in a new window/tab
|
||||||
|
globalThis.window.open(popOutUrl, "_blank");
|
||||||
|
|
||||||
|
toast.success("Window popped out");
|
||||||
|
};
|
||||||
|
|
||||||
// Copy functionality for NIPs
|
// Copy functionality for NIPs
|
||||||
const { copy, copied } = useCopy();
|
const { copy, copied } = useCopy();
|
||||||
const isNipWindow = window?.appId === "nip";
|
const isNipWindow = window?.appId === "nip";
|
||||||
@@ -154,55 +170,64 @@ export function WindowToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More actions menu - shows when multiple workspaces exist */}
|
{/* More actions menu */}
|
||||||
{hasMultipleWorkspaces && (
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="link"
|
||||||
variant="link"
|
size="icon"
|
||||||
size="icon"
|
className="text-muted-foreground"
|
||||||
className="text-muted-foreground"
|
title="More actions"
|
||||||
title="More actions"
|
aria-label="More actions"
|
||||||
aria-label="More actions"
|
>
|
||||||
>
|
<MoreVertical className="size-4" />
|
||||||
<MoreVertical className="size-4" />
|
</Button>
|
||||||
</Button>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuContent align="end">
|
{/* Pop out window */}
|
||||||
{/* Move to tab submenu */}
|
<DropdownMenuItem onClick={handlePopOut}>
|
||||||
<DropdownMenuSub>
|
<ExternalLink className="size-4 mr-2" />
|
||||||
<DropdownMenuSubTrigger
|
Pop out window
|
||||||
disabled={otherWorkspaces.length === 0}
|
</DropdownMenuItem>
|
||||||
>
|
|
||||||
<ArrowRightFromLine className="size-4 mr-2" />
|
|
||||||
Move to tab
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
{otherWorkspaces.map((ws) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={ws.id}
|
|
||||||
onClick={() => handleMoveToWorkspace(ws.id)}
|
|
||||||
>
|
|
||||||
{ws.number}
|
|
||||||
{ws.label ? ` ${ws.label}` : ""}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
|
|
||||||
{/* REQ-specific actions */}
|
{/* Move to tab submenu - only show if multiple workspaces */}
|
||||||
{isReqWindow && (
|
{hasMultipleWorkspaces && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleTurnIntoSpell}>
|
<DropdownMenuSub>
|
||||||
<WandSparkles className="size-4 mr-2" />
|
<DropdownMenuSubTrigger
|
||||||
Save as spell
|
disabled={otherWorkspaces.length === 0}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</>
|
<ArrowRightFromLine className="size-4 mr-2" />
|
||||||
)}
|
Move to tab
|
||||||
</DropdownMenuContent>
|
</DropdownMenuSubTrigger>
|
||||||
</DropdownMenu>
|
<DropdownMenuSubContent>
|
||||||
)}
|
{otherWorkspaces.map((ws) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={ws.id}
|
||||||
|
onClick={() => handleMoveToWorkspace(ws.id)}
|
||||||
|
>
|
||||||
|
{ws.number}
|
||||||
|
{ws.label ? ` ${ws.label}` : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* REQ-specific actions */}
|
||||||
|
{isReqWindow && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleTurnIntoSpell}>
|
||||||
|
<WandSparkles className="size-4 mr-2" />
|
||||||
|
Save as spell
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Spell Dialog */}
|
{/* Spell Dialog */}
|
||||||
{isReqWindow && (
|
{isReqWindow && (
|
||||||
|
|||||||
101
src/components/pages/RunCommandPage.tsx
Normal file
101
src/components/pages/RunCommandPage.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import {
|
||||||
|
parseAndExecuteCommand,
|
||||||
|
type ParsedCommand,
|
||||||
|
} from "@/lib/command-parser";
|
||||||
|
import { useGrimoire } from "@/core/state";
|
||||||
|
import { WindowRenderer } from "@/components/WindowRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RunCommandPage - Standalone command execution route
|
||||||
|
*
|
||||||
|
* Route: /run?cmd=<command>
|
||||||
|
*
|
||||||
|
* Executes a command and displays the result without affecting the workspace layout.
|
||||||
|
* This allows windows to be "popped out" into separate browser windows/tabs.
|
||||||
|
*/
|
||||||
|
export default function RunCommandPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { state } = useGrimoire();
|
||||||
|
const [parsed, setParsed] = useState<ParsedCommand | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const cmdParam = searchParams.get("cmd");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function parseCommand() {
|
||||||
|
if (!cmdParam) {
|
||||||
|
setParsed({
|
||||||
|
commandName: "",
|
||||||
|
args: [],
|
||||||
|
fullInput: "",
|
||||||
|
error: "No command provided",
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await parseAndExecuteCommand(
|
||||||
|
cmdParam,
|
||||||
|
state.activeAccount?.pubkey,
|
||||||
|
);
|
||||||
|
setParsed(result);
|
||||||
|
} catch (error) {
|
||||||
|
setParsed({
|
||||||
|
commandName: "",
|
||||||
|
args: [],
|
||||||
|
fullInput: cmdParam,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to parse command",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCommand();
|
||||||
|
}, [cmdParam, state.activeAccount?.pubkey]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background">
|
||||||
|
<div className="text-muted-foreground">Loading command...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || parsed.error || !parsed.command || !parsed.props) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background">
|
||||||
|
<div className="max-w-md rounded-lg border border-border bg-card p-6">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold text-destructive">
|
||||||
|
Command Error
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{parsed?.error || "Unknown error"}
|
||||||
|
</p>
|
||||||
|
{cmdParam && (
|
||||||
|
<p className="text-xs font-mono text-muted-foreground">
|
||||||
|
Command: {cmdParam}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a minimal WindowInstance for rendering
|
||||||
|
const windowInstance = {
|
||||||
|
id: "pop-out",
|
||||||
|
appId: parsed.command.appId,
|
||||||
|
props: parsed.props,
|
||||||
|
customTitle: parsed.globalFlags?.windowProps?.title,
|
||||||
|
commandString: parsed.fullInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WindowRenderer window={windowInstance} onClose={() => window.close()} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { AppShell } from "./components/layouts/AppShell";
|
|||||||
import DashboardPage from "./components/pages/DashboardPage";
|
import DashboardPage from "./components/pages/DashboardPage";
|
||||||
import SpellbookPage from "./components/pages/SpellbookPage";
|
import SpellbookPage from "./components/pages/SpellbookPage";
|
||||||
import Nip19PreviewRouter from "./components/pages/Nip19PreviewRouter";
|
import Nip19PreviewRouter from "./components/pages/Nip19PreviewRouter";
|
||||||
|
import RunCommandPage from "./components/pages/RunCommandPage";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -13,6 +14,14 @@ const router = createBrowserRouter([
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/run",
|
||||||
|
element: (
|
||||||
|
<AppShell hideBottomBar>
|
||||||
|
<RunCommandPage />
|
||||||
|
</AppShell>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/preview/:actor/:identifier",
|
path: "/preview/:actor/:identifier",
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Reference in New Issue
Block a user