mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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.
This commit is contained in:
@@ -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";
|
||||
@@ -139,6 +155,18 @@ export function WindowToolbar({
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Pop Out button */}
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
onClick={handlePopOut}
|
||||
title="Pop out window"
|
||||
aria-label="Pop out window"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Copy button for NIPs */}
|
||||
{isNipWindow && (
|
||||
<Button
|
||||
|
||||
124
src/components/pages/RunCommandPage.tsx
Normal file
124
src/components/pages/RunCommandPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
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";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
parsed.globalFlags?.windowProps?.title || parsed.command.name.toUpperCase();
|
||||
|
||||
// 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 (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-semibold">{title}</h1>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground">
|
||||
{parsed.fullInput}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<WindowRenderer
|
||||
window={windowInstance}
|
||||
onClose={() => window.close()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,10 @@ const router = createBrowserRouter([
|
||||
</AppShell>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/run",
|
||||
element: <RunCommandPage />,
|
||||
},
|
||||
{
|
||||
path: "/preview/:actor/:identifier",
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user