[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:
140
components/plugins/PluginAuthenticationHost.tsx
Normal file
140
components/plugins/PluginAuthenticationHost.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
pluginAuthenticationChallengeMessage,
|
||||
usePluginAuthenticationChallenges,
|
||||
} from '../../application/state/usePluginAuthenticationChallenges';
|
||||
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';
|
||||
|
||||
export const PluginAuthenticationHost: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const state = usePluginAuthenticationChallenges();
|
||||
const { challenge, busy, externalUrl } = state;
|
||||
|
||||
if (!challenge) return null;
|
||||
const message = pluginAuthenticationChallengeMessage(challenge);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) void state.complete(undefined, true); }}>
|
||||
<DialogContent className="sm:max-w-[480px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{challenge.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{message || t('plugins.authentication.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{state.isText && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plugin-authentication-value">
|
||||
{challenge.kind === 'otp'
|
||||
? t('plugins.authentication.code')
|
||||
: challenge.kind === 'password'
|
||||
? t('plugins.authentication.password')
|
||||
: t('plugins.authentication.value')}
|
||||
</Label>
|
||||
<Input
|
||||
id="plugin-authentication-value"
|
||||
type={challenge.kind === 'password' ? 'password' : 'text'}
|
||||
autoComplete={challenge.kind === 'password' ? 'current-password' : challenge.kind === 'otp' ? 'one-time-code' : 'off'}
|
||||
value={state.textValue}
|
||||
maxLength={8192}
|
||||
disabled={busy}
|
||||
onChange={(event) => state.setTextValue(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' && state.canSubmit) state.submit(); }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{challenge.kind === 'choice' && (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{challenge.choices.map((choice) => {
|
||||
const selected = state.selectedChoices.includes(choice.id);
|
||||
return (
|
||||
<label key={choice.id} className="flex cursor-pointer items-start gap-3 rounded-md border p-3">
|
||||
<input
|
||||
type={challenge.multiple ? 'checkbox' : 'radio'}
|
||||
name="plugin-authentication-choice"
|
||||
className="mt-0.5 h-4 w-4 accent-primary"
|
||||
checked={selected}
|
||||
disabled={busy}
|
||||
onChange={(event) => state.setChoiceSelected(choice.id, event.target.checked)}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium">{choice.label}</span>
|
||||
{choice.description && <span className="block text-xs text-muted-foreground">{choice.description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(challenge.kind === 'browser' || challenge.kind === 'deviceCode') && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
{challenge.kind === 'deviceCode' && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{t('plugins.authentication.deviceCode')}</div>
|
||||
<code className="select-all text-base font-semibold">{challenge.userCode}</code>
|
||||
</div>
|
||||
)}
|
||||
{externalUrl ? (
|
||||
<Button type="button" variant="outline" className="w-full" onClick={() => void state.openExternal()}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('plugins.authentication.openBrowser')}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">{t('plugins.authentication.invalidUrl')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.responseError !== null && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{state.responseError
|
||||
? t('plugins.authentication.responseFailedWithMessage', { message: state.responseError })
|
||||
: t('plugins.authentication.responseFailed')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void (challenge.kind === 'confirmation'
|
||||
? state.complete(false)
|
||||
: state.complete(undefined, true))}
|
||||
>
|
||||
{challenge.kind === 'confirmation' && challenge.cancelLabel
|
||||
? challenge.cancelLabel
|
||||
: t('common.cancel')}
|
||||
</Button>
|
||||
{challenge.kind === 'confirmation' ? (
|
||||
<Button type="button" disabled={busy} onClick={() => void state.complete(true)}>
|
||||
{challenge.confirmLabel || t('common.confirm')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" disabled={busy || !state.canSubmit} onClick={state.submit}>
|
||||
{challenge.kind === 'browser' || challenge.kind === 'deviceCode'
|
||||
? t('plugins.authentication.continue')
|
||||
: t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
86
components/plugins/PluginContributionHost.tsx
Normal file
86
components/plugins/PluginContributionHost.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
requestOpenPluginView,
|
||||
usePluginViewLifecycle,
|
||||
} from '../../application/state/usePluginViewLifecycle';
|
||||
import { Button } from '../ui/button';
|
||||
import { PluginContributionIcon } from './PluginContributionIcon';
|
||||
|
||||
export { requestOpenPluginView };
|
||||
|
||||
const DEFAULT_KEYBINDING_CONTEXT = Object.freeze({ 'netcatty.surface': 'keybinding' });
|
||||
|
||||
export function PluginContributionHost({
|
||||
locale,
|
||||
theme,
|
||||
themeTokens: suppliedThemeTokens,
|
||||
keybindingContext = DEFAULT_KEYBINDING_CONTEXT,
|
||||
}: {
|
||||
locale: string;
|
||||
theme: string;
|
||||
themeTokens?: Record<string, string>;
|
||||
keybindingContext?: Record<string, unknown>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
activeView,
|
||||
close,
|
||||
effectiveRequested,
|
||||
mountRef,
|
||||
} = usePluginViewLifecycle({
|
||||
locale,
|
||||
theme,
|
||||
suppliedThemeTokens,
|
||||
keybindingContext,
|
||||
});
|
||||
|
||||
if (!effectiveRequested || !activeView) return null;
|
||||
const location = activeView.view.location;
|
||||
const containerClass = location === 'aside'
|
||||
? 'absolute inset-y-0 right-0 z-40 w-[420px] border-l border-border bg-background shadow-2xl'
|
||||
: location === 'panel'
|
||||
? 'absolute inset-x-0 bottom-0 z-40 h-[42%] border-t border-border bg-background shadow-2xl'
|
||||
: location === 'modal'
|
||||
? 'fixed left-1/2 top-1/2 z-50 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
|
||||
: 'absolute inset-0 z-40 bg-background';
|
||||
|
||||
if (location === 'tab') {
|
||||
return (
|
||||
<section className={`${containerClass} flex flex-col`} role="region" aria-label={activeView.view.title}>
|
||||
<div ref={mountRef} className="min-h-0 flex-1" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`${containerClass} flex flex-col`}
|
||||
role={location === 'modal' ? 'dialog' : 'region'}
|
||||
aria-modal={location === 'modal' ? true : undefined}
|
||||
aria-label={activeView.view.title}
|
||||
>
|
||||
<header className="app-no-drag flex h-11 shrink-0 items-center justify-between border-b border-border px-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<PluginContributionIcon pluginId={activeView.plugin.id} icon={activeView.view.icon} className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{activeView.view.title}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{activeView.plugin.displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => void close()}
|
||||
aria-label={t('common.close')}
|
||||
autoFocus={location === 'modal'}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</header>
|
||||
<div ref={mountRef} className="min-h-0 flex-1" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
components/plugins/PluginContributionIcon.tsx
Normal file
80
components/plugins/PluginContributionIcon.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Code,
|
||||
Command,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
Key,
|
||||
LayoutPanelLeft,
|
||||
List,
|
||||
Network,
|
||||
Palette,
|
||||
Play,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Shield,
|
||||
Table,
|
||||
Terminal,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { usePluginContributionIcon } from '../../application/state/usePluginContributionIcon';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const THEME_ICONS: Readonly<Record<string, LucideIcon>> = Object.freeze({
|
||||
activity: Activity,
|
||||
box: Box,
|
||||
code: Code,
|
||||
command: Command,
|
||||
file: FileText,
|
||||
folder: Folder,
|
||||
globe: Globe,
|
||||
key: Key,
|
||||
'layout-panel': LayoutPanelLeft,
|
||||
list: List,
|
||||
network: Network,
|
||||
palette: Palette,
|
||||
play: Play,
|
||||
settings: Settings,
|
||||
shield: Shield,
|
||||
table: Table,
|
||||
terminal: Terminal,
|
||||
wrench: Wrench,
|
||||
});
|
||||
|
||||
export function PluginContributionIcon({
|
||||
pluginId,
|
||||
icon,
|
||||
size = 14,
|
||||
className,
|
||||
}: {
|
||||
pluginId?: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const packageIcon = usePluginContributionIcon(pluginId, icon);
|
||||
|
||||
if (icon?.kind === 'theme') {
|
||||
const Icon = THEME_ICONS[icon.name] ?? Puzzle;
|
||||
return <Icon size={size} className={className} aria-hidden="true" data-plugin-icon-kind="theme" />;
|
||||
}
|
||||
if (packageIcon) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center justify-center', className)}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden="true"
|
||||
data-plugin-icon-kind="package"
|
||||
>
|
||||
<img src={packageIcon.light} alt="" width={size} height={size} className={packageIcon.dark ? 'h-full w-full object-contain dark:hidden' : 'h-full w-full object-contain'} />
|
||||
{packageIcon.dark && <img src={packageIcon.dark} alt="" width={size} height={size} className="hidden h-full w-full object-contain dark:block" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <Puzzle size={size} className={className} aria-hidden="true" data-plugin-icon-kind="fallback" />;
|
||||
}
|
||||
25
components/plugins/PluginProviderLayering.test.ts
Normal file
25
components/plugins/PluginProviderLayering.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const pluginConnectionSectionSource = readFileSync(new URL("../PluginConnectionSection.tsx", import.meta.url), "utf8");
|
||||
const pluginAuthenticationHostSource = readFileSync(new URL("./PluginAuthenticationHost.tsx", import.meta.url), "utf8");
|
||||
const connectionHookSource = readFileSync(new URL("../../application/state/usePluginConnectionSectionState.ts", import.meta.url), "utf8");
|
||||
const authenticationHookSource = readFileSync(new URL("../../application/state/usePluginAuthenticationChallenges.ts", import.meta.url), "utf8");
|
||||
|
||||
test("plugin connection section delegates provider discovery and credential catalog state to application state", () => {
|
||||
assert.match(connectionHookSource, /pluginExtensionBridge\.listProviders\("connection"\)/u);
|
||||
assert.match(connectionHookSource, /pluginExtensionBridge\.onContributionsChanged/u);
|
||||
assert.match(connectionHookSource, /pluginExtensionBridge\.subscribeCredentialCatalog/u);
|
||||
assert.doesNotMatch(pluginConnectionSectionSource, /pluginExtensionBridge/u);
|
||||
});
|
||||
|
||||
test("plugin authentication host delegates challenge lifecycle effects to application state", () => {
|
||||
assert.match(authenticationHookSource, /pluginExtensionBridge\.onAuthenticationChallenge/u);
|
||||
assert.match(authenticationHookSource, /pluginExtensionBridge\.respondAuthenticationChallenge/u);
|
||||
assert.match(authenticationHookSource, /catch \(error\) \{\s+setResponseError\(pluginAuthenticationResponseErrorMessage\(error\)\);/u);
|
||||
assert.doesNotMatch(authenticationHookSource, /catch \{\s+setQueue\(\(existing\) => existing\.filter/u);
|
||||
assert.match(pluginAuthenticationHostSource, /role="alert"/u);
|
||||
assert.match(pluginAuthenticationHostSource, /plugins\.authentication\.responseFailed/u);
|
||||
assert.doesNotMatch(pluginAuthenticationHostSource, /pluginExtensionBridge/u);
|
||||
});
|
||||
Reference in New Issue
Block a user