[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
234
components/SnippetExecutionProvider.tsx
Normal file
234
components/SnippetExecutionProvider.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { Snippet } from '../domain/models';
|
||||
import {
|
||||
applySnippetVariables,
|
||||
parseSnippetVariables,
|
||||
previewSnippetCommand,
|
||||
snippetHasVariables,
|
||||
type SnippetVariableDef,
|
||||
} from '../domain/snippetVariables';
|
||||
import {
|
||||
readSnippetVariableValuesForSnippet,
|
||||
saveSnippetVariableValues,
|
||||
} from '../application/state/snippetVariableValues';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface PendingPrompt {
|
||||
snippet: Snippet;
|
||||
variables: SnippetVariableDef[];
|
||||
resolve: (values: Record<string, string> | null) => void;
|
||||
}
|
||||
|
||||
function buildInitialValues(
|
||||
snippet: Snippet,
|
||||
variables: SnippetVariableDef[],
|
||||
): Record<string, string> {
|
||||
const cached = readSnippetVariableValuesForSnippet(snippet.id);
|
||||
const values: Record<string, string> = {};
|
||||
for (const def of variables) {
|
||||
if (cached[def.name] !== undefined) {
|
||||
values[def.name] = cached[def.name];
|
||||
} else if (def.defaultValue !== undefined) {
|
||||
values[def.name] = def.defaultValue;
|
||||
} else {
|
||||
values[def.name] = '';
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function isFormValid(
|
||||
variables: SnippetVariableDef[],
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
for (const def of variables) {
|
||||
const raw = values[def.name] ?? '';
|
||||
if (raw.trim() === '' && def.defaultValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const SnippetExecutionProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [pending, setPending] = useState<PendingPrompt | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const pendingRef = useRef<PendingPrompt | null>(null);
|
||||
pendingRef.current = pending;
|
||||
|
||||
const prompt = useCallback(
|
||||
(snippet: Snippet) =>
|
||||
new Promise<Record<string, string> | null>((resolve) => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) prior.resolve(null);
|
||||
|
||||
const variables = parseSnippetVariables(snippet.command);
|
||||
if (variables.length === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
setValues(buildInitialValues(snippet, variables));
|
||||
setPending({ snippet, variables, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
promptSnippetVariablesSingleton = prompt;
|
||||
return () => {
|
||||
promptSnippetVariablesSingleton = null;
|
||||
};
|
||||
}, [prompt]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) {
|
||||
prior.resolve(null);
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!pending) return '';
|
||||
return previewSnippetCommand(pending.snippet.command, values);
|
||||
}, [pending, values]);
|
||||
|
||||
const canSubmit = pending ? isFormValid(pending.variables, values) : false;
|
||||
|
||||
const closeWith = useCallback((result: Record<string, string> | null) => {
|
||||
if (!pending) return;
|
||||
pending.resolve(result);
|
||||
setPending(null);
|
||||
}, [pending]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!pending || !canSubmit) return;
|
||||
const result = applySnippetVariables(pending.snippet.command, values);
|
||||
if (!result.ok) return;
|
||||
saveSnippetVariableValues(pending.snippet.id, values);
|
||||
closeWith(values);
|
||||
}, [pending, canSubmit, values, closeWith]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && canSubmit) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[canSubmit, handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Dialog open={!!pending} onOpenChange={(open) => { if (!open) closeWith(null); }}>
|
||||
<DialogContent className="sm:max-w-[520px]" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.variables.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.variables.dialogDesc', { label: pending?.snippet.label ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{pending && (
|
||||
<div className="space-y-4 py-1">
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.variables.hint')}</p>
|
||||
<div className="space-y-3">
|
||||
{pending.variables.map((def) => {
|
||||
const raw = values[def.name] ?? '';
|
||||
const invalid = raw.trim() === '' && def.defaultValue === undefined;
|
||||
return (
|
||||
<div key={def.name} className="space-y-1.5">
|
||||
<Label htmlFor={`snippet-var-${def.name}`}>{def.name}</Label>
|
||||
<Input
|
||||
id={`snippet-var-${def.name}`}
|
||||
value={raw}
|
||||
placeholder={
|
||||
def.defaultValue !== undefined
|
||||
? t('snippets.variables.placeholderDefault', { value: def.defaultValue })
|
||||
: t('snippets.variables.placeholder')
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setValues((prev) => ({ ...prev, [def.name]: next }));
|
||||
}}
|
||||
className={invalid ? 'border-destructive' : undefined}
|
||||
autoFocus={def === pending.variables[0]}
|
||||
/>
|
||||
{invalid && (
|
||||
<p className="text-xs text-destructive">{t('snippets.variables.required')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-muted-foreground">
|
||||
{t('snippets.variables.preview')}
|
||||
</p>
|
||||
<ScrollArea className="max-h-32 rounded-md border border-border/60 bg-muted/30">
|
||||
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all">
|
||||
{preview}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={() => closeWith(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="default" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{t('snippets.variables.run')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
let promptSnippetVariablesSingleton:
|
||||
| ((snippet: Snippet) => Promise<Record<string, string> | null>)
|
||||
| null = null;
|
||||
|
||||
export async function resolveSnippetCommand(snippet: Snippet): Promise<string | null> {
|
||||
if (!snippetHasVariables(snippet.command)) {
|
||||
return snippet.command;
|
||||
}
|
||||
|
||||
const promptFn = promptSnippetVariablesSingleton;
|
||||
if (!promptFn) {
|
||||
return snippet.command;
|
||||
}
|
||||
|
||||
const values = await promptFn(snippet);
|
||||
if (values === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = applySnippetVariables(snippet.command, values);
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
return result.command;
|
||||
}
|
||||
Reference in New Issue
Block a user