mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +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 "./command-launcher.css";
|
||||
|
||||
/** Check if current path is a NIP-19 preview route (no window system) */
|
||||
function isNip19PreviewRoute(pathname: string): boolean {
|
||||
/** Check if current path doesn't have the window system (should navigate to / when launching commands) */
|
||||
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
|
||||
const segment = pathname.slice(1); // Remove leading /
|
||||
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
|
||||
} 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
|
||||
if (isNip19PreviewRoute(location.pathname)) {
|
||||
if (isNonDashboardRoute(location.pathname)) {
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
ArrowRightFromLine,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
@@ -91,6 +92,21 @@ export function WindowToolbar({
|
||||
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
|
||||
const { copy, copied } = useCopy();
|
||||
const isNipWindow = window?.appId === "nip";
|
||||
@@ -154,55 +170,64 @@ export function WindowToolbar({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More actions menu - shows when multiple workspaces exist */}
|
||||
{hasMultipleWorkspaces && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* Move to tab submenu */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={otherWorkspaces.length === 0}
|
||||
>
|
||||
<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>
|
||||
{/* More actions menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* Pop out window */}
|
||||
<DropdownMenuItem onClick={handlePopOut}>
|
||||
<ExternalLink className="size-4 mr-2" />
|
||||
Pop out window
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* REQ-specific actions */}
|
||||
{isReqWindow && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleTurnIntoSpell}>
|
||||
<WandSparkles className="size-4 mr-2" />
|
||||
Save as spell
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{/* Move to tab submenu - only show if multiple workspaces */}
|
||||
{hasMultipleWorkspaces && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={otherWorkspaces.length === 0}
|
||||
>
|
||||
<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 */}
|
||||
{isReqWindow && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleTurnIntoSpell}>
|
||||
<WandSparkles className="size-4 mr-2" />
|
||||
Save as spell
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Spell Dialog */}
|
||||
{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 SpellbookPage from "./components/pages/SpellbookPage";
|
||||
import Nip19PreviewRouter from "./components/pages/Nip19PreviewRouter";
|
||||
import RunCommandPage from "./components/pages/RunCommandPage";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -13,6 +14,14 @@ const router = createBrowserRouter([
|
||||
</AppShell>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/run",
|
||||
element: (
|
||||
<AppShell hideBottomBar>
|
||||
<RunCommandPage />
|
||||
</AppShell>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/preview/:actor/:identifier",
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user