mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-08 13:49:40 +02:00
feat: editable commands
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { useAtom } from "jotai";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { manPages } from "@/types/man";
|
||||
import { parseCommandInput, executeCommandParser } from "@/lib/command-parser";
|
||||
import { commandLauncherEditModeAtom } from "@/core/command-launcher-state";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
||||
import "./command-launcher.css";
|
||||
@@ -16,24 +19,22 @@ export default function CommandLauncher({
|
||||
onOpenChange,
|
||||
}: CommandLauncherProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const { addWindow } = useGrimoire();
|
||||
const [editMode, setEditMode] = useAtom(commandLauncherEditModeAtom);
|
||||
const { addWindow, updateWindow } = useGrimoire();
|
||||
|
||||
// Prefill input when entering edit mode
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (open && editMode) {
|
||||
setInput(editMode.initialCommand);
|
||||
} else if (!open) {
|
||||
setInput("");
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, editMode]);
|
||||
|
||||
// Parse input into command and arguments
|
||||
const parseInput = (value: string) => {
|
||||
const parts = value.trim().split(/\s+/);
|
||||
const commandName = parts[0]?.toLowerCase() || "";
|
||||
const args = parts.slice(1);
|
||||
return { commandName, args, fullInput: value };
|
||||
};
|
||||
|
||||
const { commandName, args } = parseInput(input);
|
||||
const recognizedCommand = commandName && manPages[commandName];
|
||||
const parsed = parseCommandInput(input);
|
||||
const { commandName } = parsed;
|
||||
const recognizedCommand = parsed.command;
|
||||
|
||||
// Filter commands by partial match on command name only
|
||||
const filteredCommands = Object.entries(manPages).filter(([name]) =>
|
||||
@@ -44,22 +45,33 @@ export default function CommandLauncher({
|
||||
const executeCommand = async () => {
|
||||
if (!recognizedCommand) return;
|
||||
|
||||
const command = recognizedCommand;
|
||||
// Execute argParser and get props/title
|
||||
const result = await executeCommandParser(parsed);
|
||||
|
||||
// Use argParser if available, otherwise use defaultProps
|
||||
// argParser can now be async
|
||||
const props = command.argParser
|
||||
? await Promise.resolve(command.argParser(args))
|
||||
: command.defaultProps || {};
|
||||
if (result.error || !result.props) {
|
||||
console.error("Failed to parse command:", result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate title
|
||||
const title =
|
||||
args.length > 0
|
||||
? `${commandName.toUpperCase()} ${args.join(" ")}`
|
||||
: commandName.toUpperCase();
|
||||
// Edit mode: update existing window
|
||||
if (editMode) {
|
||||
updateWindow(editMode.windowId, {
|
||||
props: result.props,
|
||||
title: result.title,
|
||||
commandString: input.trim(),
|
||||
appId: recognizedCommand.appId,
|
||||
});
|
||||
setEditMode(null); // Clear edit mode
|
||||
} else {
|
||||
// Normal mode: create new window
|
||||
addWindow(
|
||||
recognizedCommand.appId,
|
||||
result.props,
|
||||
result.title,
|
||||
input.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute command
|
||||
addWindow(command.appId, props, title);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -111,11 +123,11 @@ export default function CommandLauncher({
|
||||
className="command-input"
|
||||
/>
|
||||
|
||||
{recognizedCommand && args.length > 0 && (
|
||||
{recognizedCommand && parsed.args.length > 0 && (
|
||||
<div className="command-hint">
|
||||
<span className="command-hint-label">Parsed:</span>
|
||||
<span className="command-hint-command">{commandName}</span>
|
||||
<span className="command-hint-args">{args.join(" ")}</span>
|
||||
<span className="command-hint-args">{parsed.args.join(" ")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function Home() {
|
||||
window={window}
|
||||
path={path}
|
||||
onClose={handleRemoveWindow}
|
||||
onEditCommand={() => setCommandLauncherOpen(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,29 +12,31 @@ export function TabBar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1">
|
||||
{Object.values(workspaces).map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => setActiveWorkspace(ws.id)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs font-mono rounded transition-colors",
|
||||
ws.id === activeWorkspaceId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
|
||||
<div className="flex items-center gap-1 flex-nowrap">
|
||||
{Object.values(workspaces).map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => setActiveWorkspace(ws.id)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0",
|
||||
ws.id === activeWorkspaceId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{ws.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-1 flex-shrink-0"
|
||||
onClick={handleNewTab}
|
||||
>
|
||||
{ws.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-1"
|
||||
onClick={handleNewTab}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,16 @@ interface WindowTileProps {
|
||||
window: WindowInstance;
|
||||
path: MosaicBranch[];
|
||||
onClose: (id: string) => void;
|
||||
onEditCommand: () => void; // Callback to open CommandLauncher
|
||||
}
|
||||
|
||||
export function WindowTile({ id, window, path, onClose }: WindowTileProps) {
|
||||
export function WindowTile({
|
||||
id,
|
||||
window,
|
||||
path,
|
||||
onClose,
|
||||
onEditCommand,
|
||||
}: WindowTileProps) {
|
||||
const { title, icon, tooltip } = useDynamicWindowTitle(window);
|
||||
const Icon = icon;
|
||||
|
||||
@@ -29,7 +36,11 @@ export function WindowTile({ id, window, path, onClose }: WindowTileProps) {
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<WindowToolbar onClose={() => onClose(id)} />
|
||||
<WindowToolbar
|
||||
window={window}
|
||||
onClose={() => onClose(id)}
|
||||
onEditCommand={onEditCommand}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
import { X } from "lucide-react";
|
||||
import { X, Pencil } from "lucide-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { WindowInstance } from "@/types/app";
|
||||
import { commandLauncherEditModeAtom } from "@/core/command-launcher-state";
|
||||
import { reconstructCommand } from "@/lib/command-reconstructor";
|
||||
|
||||
interface WindowToolbarProps {
|
||||
window?: WindowInstance;
|
||||
onClose?: () => void;
|
||||
onEditCommand?: () => void; // Callback to open CommandLauncher
|
||||
}
|
||||
|
||||
export function WindowToolbar({ onClose }: WindowToolbarProps) {
|
||||
export function WindowToolbar({
|
||||
window,
|
||||
onClose,
|
||||
onEditCommand,
|
||||
}: WindowToolbarProps) {
|
||||
const setEditMode = useSetAtom(commandLauncherEditModeAtom);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!window) return;
|
||||
|
||||
// Get command string (existing or reconstructed)
|
||||
const commandString =
|
||||
window.commandString || reconstructCommand(window);
|
||||
|
||||
// Set edit mode state
|
||||
setEditMode({
|
||||
windowId: window.id,
|
||||
initialCommand: commandString,
|
||||
});
|
||||
|
||||
// Open CommandLauncher
|
||||
if (onEditCommand) {
|
||||
onEditCommand();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{window && (
|
||||
<button
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
onClick={handleEdit}
|
||||
title="Edit command"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
|
||||
16
src/core/command-launcher-state.ts
Normal file
16
src/core/command-launcher-state.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
/**
|
||||
* Edit mode state for CommandLauncher.
|
||||
* When set, CommandLauncher opens in edit mode for the specified window.
|
||||
*/
|
||||
export interface EditModeState {
|
||||
windowId: string;
|
||||
initialCommand: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atom to control edit mode in CommandLauncher.
|
||||
* Set this to trigger edit mode, null for normal create mode.
|
||||
*/
|
||||
export const commandLauncherEditModeAtom = atom<EditModeState | null>(null);
|
||||
@@ -30,7 +30,7 @@ export const createWorkspace = (
|
||||
*/
|
||||
export const addWindow = (
|
||||
state: GrimoireState,
|
||||
payload: { appId: string; title: string; props: any },
|
||||
payload: { appId: string; title: string; props: any; commandString?: string },
|
||||
): GrimoireState => {
|
||||
const activeId = state.activeWorkspaceId;
|
||||
const ws = state.workspaces[activeId];
|
||||
@@ -40,6 +40,7 @@ export const addWindow = (
|
||||
appId: payload.appId as any,
|
||||
title: payload.title,
|
||||
props: payload.props,
|
||||
commandString: payload.commandString,
|
||||
};
|
||||
|
||||
// Simple Binary Split Logic
|
||||
@@ -263,3 +264,56 @@ export const setActiveAccountRelays = (
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a workspace by ID.
|
||||
* Cannot delete the last remaining workspace.
|
||||
* Does NOT change activeWorkspaceId - caller is responsible for workspace navigation.
|
||||
*/
|
||||
export const deleteWorkspace = (
|
||||
state: GrimoireState,
|
||||
workspaceId: string,
|
||||
): GrimoireState => {
|
||||
const workspaceIds = Object.keys(state.workspaces);
|
||||
|
||||
// Don't delete if it's the only workspace
|
||||
if (workspaceIds.length <= 1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Don't delete if workspace doesn't exist
|
||||
if (!state.workspaces[workspaceId]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Remove the workspace (don't touch activeWorkspaceId - that's the caller's job)
|
||||
const { [workspaceId]: _removed, ...remainingWorkspaces } = state.workspaces;
|
||||
|
||||
return {
|
||||
...state,
|
||||
workspaces: remainingWorkspaces,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing window with new properties.
|
||||
* Allows updating props, title, commandString, and even appId (which changes the viewer type).
|
||||
*/
|
||||
export const updateWindow = (
|
||||
state: GrimoireState,
|
||||
windowId: string,
|
||||
updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>,
|
||||
): GrimoireState => {
|
||||
const window = state.windows[windowId];
|
||||
if (!window) {
|
||||
return state; // Window doesn't exist, return unchanged
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
windows: {
|
||||
...state.windows,
|
||||
[windowId]: { ...window, ...updates },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { GrimoireState, AppId } from "@/types/app";
|
||||
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import * as Logic from "./logic";
|
||||
|
||||
@@ -82,14 +82,17 @@ export const useGrimoire = () => {
|
||||
const count = Object.keys(state.workspaces).length + 1;
|
||||
setState((prev) => Logic.createWorkspace(prev, count.toString()));
|
||||
},
|
||||
addWindow: (appId: AppId, props: any, title?: string) =>
|
||||
addWindow: (appId: AppId, props: any, title?: string, commandString?: string) =>
|
||||
setState((prev) =>
|
||||
Logic.addWindow(prev, {
|
||||
appId,
|
||||
props,
|
||||
title: title || appId.toUpperCase(),
|
||||
commandString,
|
||||
}),
|
||||
),
|
||||
updateWindow: (windowId: string, updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>) =>
|
||||
setState((prev) => Logic.updateWindow(prev, windowId, updates)),
|
||||
removeWindow: (id: string) =>
|
||||
setState((prev) => Logic.removeWindow(prev, id)),
|
||||
moveWindowToWorkspace: (windowId: string, targetWorkspaceId: string) =>
|
||||
@@ -99,7 +102,37 @@ export const useGrimoire = () => {
|
||||
updateLayout: (layout: any) =>
|
||||
setState((prev) => Logic.updateLayout(prev, layout)),
|
||||
setActiveWorkspace: (id: string) =>
|
||||
setState((prev) => ({ ...prev, activeWorkspaceId: id })),
|
||||
setState((prev) => {
|
||||
// Validate target workspace exists
|
||||
if (!prev.workspaces[id]) {
|
||||
console.warn(`Cannot switch to non-existent workspace: ${id}`);
|
||||
return prev;
|
||||
}
|
||||
|
||||
// If not actually switching, return unchanged
|
||||
if (prev.activeWorkspaceId === id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Check if we're leaving an empty workspace and should auto-remove it
|
||||
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
|
||||
const shouldDeleteCurrent =
|
||||
currentWorkspace &&
|
||||
currentWorkspace.windowIds.length === 0 &&
|
||||
Object.keys(prev.workspaces).length > 1;
|
||||
|
||||
if (shouldDeleteCurrent) {
|
||||
// Delete the empty workspace, then switch to target
|
||||
const afterDelete = Logic.deleteWorkspace(
|
||||
prev,
|
||||
prev.activeWorkspaceId,
|
||||
);
|
||||
return { ...afterDelete, activeWorkspaceId: id };
|
||||
}
|
||||
|
||||
// Normal workspace switch
|
||||
return { ...prev, activeWorkspaceId: id };
|
||||
}),
|
||||
setActiveAccount: (pubkey: string | undefined) =>
|
||||
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
|
||||
setActiveAccountRelays: (relays: any) =>
|
||||
|
||||
102
src/lib/command-parser.ts
Normal file
102
src/lib/command-parser.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { manPages } from "@/types/man";
|
||||
|
||||
export interface ParsedCommand {
|
||||
commandName: string;
|
||||
args: string[];
|
||||
fullInput: string;
|
||||
command?: typeof manPages[string];
|
||||
props?: any;
|
||||
title?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a command string into its components.
|
||||
* Returns basic parsing info without executing argParser.
|
||||
*/
|
||||
export function parseCommandInput(input: string): ParsedCommand {
|
||||
const parts = input.trim().split(/\s+/);
|
||||
const commandName = parts[0]?.toLowerCase() || "";
|
||||
const args = parts.slice(1);
|
||||
const fullInput = input.trim();
|
||||
|
||||
const command = commandName && manPages[commandName];
|
||||
|
||||
if (!commandName) {
|
||||
return {
|
||||
commandName: "",
|
||||
args: [],
|
||||
fullInput: "",
|
||||
error: "No command provided",
|
||||
};
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
commandName,
|
||||
args,
|
||||
fullInput,
|
||||
error: `Unknown command: ${commandName}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commandName,
|
||||
args,
|
||||
fullInput,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the argParser for a command and returns complete parsed command data.
|
||||
* This is async to support commands like profile that use NIP-05 resolution.
|
||||
*/
|
||||
export async function executeCommandParser(
|
||||
parsed: ParsedCommand,
|
||||
): Promise<ParsedCommand> {
|
||||
if (!parsed.command) {
|
||||
return parsed; // Already has error, return as-is
|
||||
}
|
||||
|
||||
try {
|
||||
// Use argParser if available, otherwise use defaultProps
|
||||
const props = parsed.command.argParser
|
||||
? await Promise.resolve(parsed.command.argParser(parsed.args))
|
||||
: parsed.command.defaultProps || {};
|
||||
|
||||
// Generate title
|
||||
const title =
|
||||
parsed.args.length > 0
|
||||
? `${parsed.commandName.toUpperCase()} ${parsed.args.join(" ")}`
|
||||
: parsed.commandName.toUpperCase();
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
props,
|
||||
title,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...parsed,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to parse command arguments",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete command parsing pipeline: parse input → execute argParser.
|
||||
* Returns fully parsed command ready for window creation.
|
||||
*/
|
||||
export async function parseAndExecuteCommand(
|
||||
input: string,
|
||||
): Promise<ParsedCommand> {
|
||||
const parsed = parseCommandInput(input);
|
||||
if (parsed.error || !parsed.command) {
|
||||
return parsed;
|
||||
}
|
||||
return executeCommandParser(parsed);
|
||||
}
|
||||
196
src/lib/command-reconstructor.ts
Normal file
196
src/lib/command-reconstructor.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { WindowInstance } from "@/types/app";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
/**
|
||||
* Reconstructs the command string that would have created this window.
|
||||
* Used for windows created before commandString tracking was added.
|
||||
*/
|
||||
export function reconstructCommand(window: WindowInstance): string {
|
||||
const { appId, props } = window;
|
||||
|
||||
try {
|
||||
switch (appId) {
|
||||
case "nip":
|
||||
return `nip ${props.number || "01"}`;
|
||||
|
||||
case "kind":
|
||||
return `kind ${props.number || "1"}`;
|
||||
|
||||
case "kinds":
|
||||
return "kinds";
|
||||
|
||||
case "man":
|
||||
return props.cmd && props.cmd !== "help"
|
||||
? `man ${props.cmd}`
|
||||
: "help";
|
||||
|
||||
case "profile": {
|
||||
// Try to encode pubkey as npub for readability
|
||||
if (props.pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(props.pubkey);
|
||||
return `profile ${npub}`;
|
||||
} catch {
|
||||
// If encoding fails, use hex
|
||||
return `profile ${props.pubkey}`;
|
||||
}
|
||||
}
|
||||
return "profile";
|
||||
}
|
||||
|
||||
case "open": {
|
||||
// Try to encode event ID as note or use hex
|
||||
if (props.id) {
|
||||
try {
|
||||
const note = nip19.noteEncode(props.id);
|
||||
return `open ${note}`;
|
||||
} catch {
|
||||
return `open ${props.id}`;
|
||||
}
|
||||
}
|
||||
// Address pointer format: kind:pubkey:d-tag
|
||||
if (props.address) {
|
||||
return `open ${props.address}`;
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
case "relay":
|
||||
return props.url ? `relay ${props.url}` : "relay";
|
||||
|
||||
case "conn":
|
||||
return "conn";
|
||||
|
||||
case "encode":
|
||||
// Best effort reconstruction
|
||||
return props.args ? `encode ${props.args.join(" ")}` : "encode";
|
||||
|
||||
case "decode":
|
||||
return props.args ? `decode ${props.args[0] || ""}` : "decode";
|
||||
|
||||
case "req": {
|
||||
// Reconstruct req command from filter object
|
||||
return reconstructReqCommand(props);
|
||||
}
|
||||
|
||||
case "feed":
|
||||
return reconstructFeedCommand(props);
|
||||
|
||||
case "debug":
|
||||
return "debug";
|
||||
|
||||
case "win":
|
||||
return "win";
|
||||
|
||||
default:
|
||||
return appId; // Fallback to just the command name
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reconstruct command:", error);
|
||||
return appId; // Fallback to just the command name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a req command from its filter props.
|
||||
* This is complex as req has many flags.
|
||||
*/
|
||||
function reconstructReqCommand(props: any): string {
|
||||
const parts = ["req"];
|
||||
const filter = props.filter || {};
|
||||
|
||||
// Kinds
|
||||
if (filter.kinds && filter.kinds.length > 0) {
|
||||
parts.push("-k", filter.kinds.join(","));
|
||||
}
|
||||
|
||||
// Authors (convert hex to npub if possible)
|
||||
if (filter.authors && filter.authors.length > 0) {
|
||||
const authors = filter.authors.map((hex: string) => {
|
||||
try {
|
||||
return nip19.npubEncode(hex);
|
||||
} catch {
|
||||
return hex;
|
||||
}
|
||||
});
|
||||
parts.push("-a", authors.join(","));
|
||||
}
|
||||
|
||||
// Limit
|
||||
if (filter.limit) {
|
||||
parts.push("-l", filter.limit.toString());
|
||||
}
|
||||
|
||||
// Event IDs (#e tag)
|
||||
if (filter["#e"] && filter["#e"].length > 0) {
|
||||
parts.push("-e", filter["#e"].join(","));
|
||||
}
|
||||
|
||||
// Mentioned pubkeys (#p tag)
|
||||
if (filter["#p"] && filter["#p"].length > 0) {
|
||||
const pubkeys = filter["#p"].map((hex: string) => {
|
||||
try {
|
||||
return nip19.npubEncode(hex);
|
||||
} catch {
|
||||
return hex;
|
||||
}
|
||||
});
|
||||
parts.push("-p", pubkeys.join(","));
|
||||
}
|
||||
|
||||
// Hashtags (#t tag)
|
||||
if (filter["#t"] && filter["#t"].length > 0) {
|
||||
parts.push("-t", filter["#t"].join(","));
|
||||
}
|
||||
|
||||
// D-tags (#d tag)
|
||||
if (filter["#d"] && filter["#d"].length > 0) {
|
||||
parts.push("-d", filter["#d"].join(","));
|
||||
}
|
||||
|
||||
// Generic tags
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key.startsWith("#") && key.length === 2 && !["#e", "#p", "#t", "#d"].includes(key)) {
|
||||
const letter = key[1];
|
||||
const values = value as string[];
|
||||
if (values.length > 0) {
|
||||
parts.push("--tag", letter, values.join(","));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Time ranges
|
||||
if (filter.since) {
|
||||
parts.push("--since", filter.since.toString());
|
||||
}
|
||||
|
||||
if (filter.until) {
|
||||
parts.push("--until", filter.until.toString());
|
||||
}
|
||||
|
||||
// Search
|
||||
if (filter.search) {
|
||||
parts.push("--search", filter.search);
|
||||
}
|
||||
|
||||
// Close on EOSE
|
||||
if (props.closeOnEose) {
|
||||
parts.push("--close-on-eose");
|
||||
}
|
||||
|
||||
// Relays
|
||||
if (props.relays && props.relays.length > 0) {
|
||||
parts.push(...props.relays);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a feed command from its props.
|
||||
*/
|
||||
function reconstructFeedCommand(props: any): string {
|
||||
// Feed command structure depends on implementation
|
||||
// This is a best-effort reconstruction
|
||||
return "feed";
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface WindowInstance {
|
||||
appId: AppId;
|
||||
title: string;
|
||||
props: any;
|
||||
commandString?: string; // Original command that created this window (e.g., "profile alice@domain.com")
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
|
||||
Reference in New Issue
Block a user