- Refactored cross-team message formatting to use a canonical XML-like tag structure for improved clarity and consistency. - Introduced new attributes for cross-team messages, including 'from', 'depth', 'conversationId', and 'replyToConversationId'. - Enhanced TeamProvisioningService to manage pending cross-team reply expectations, ensuring proper message handling and relay. - Updated UI components to reflect changes in cross-team messaging, including stripping metadata from displayed messages. - Added tests to validate new cross-team message handling and ensure proper functionality across services.
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
/**
|
|
* WorkspaceSection - Settings section for managing saved SSH connection profiles.
|
|
*
|
|
* Provides CRUD UI for:
|
|
* - Listing saved SSH profiles
|
|
* - Adding new profiles (name, host, port, username, auth method)
|
|
* - Inline editing existing profile fields
|
|
* - Deleting profiles with confirmation
|
|
*
|
|
* Profile changes persist via ConfigManager and trigger context list refresh.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { useStore } from '@renderer/store';
|
|
import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react';
|
|
|
|
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
|
import { SettingsSelect } from '../components/SettingsSelect';
|
|
|
|
import type { SshAuthMethod, SshConnectionProfile } from '@shared/types';
|
|
|
|
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
|
const inputStyle = {
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
borderColor: 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
};
|
|
|
|
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
|
{ value: 'auto', label: 'Auto (from SSH Config)' },
|
|
{ value: 'agent', label: 'SSH Agent' },
|
|
{ value: 'privateKey', label: 'Private Key' },
|
|
{ value: 'password', label: 'Password' },
|
|
];
|
|
|
|
const defaultForm = {
|
|
name: '',
|
|
host: '',
|
|
port: '22',
|
|
username: '',
|
|
authMethod: 'auto' as SshAuthMethod,
|
|
privateKeyPath: '',
|
|
};
|
|
|
|
export const WorkspaceSection = (): React.JSX.Element => {
|
|
const [profiles, setProfiles] = useState<SshConnectionProfile[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
|
|
// Form state
|
|
const [formName, setFormName] = useState(defaultForm.name);
|
|
const [formHost, setFormHost] = useState(defaultForm.host);
|
|
const [formPort, setFormPort] = useState(defaultForm.port);
|
|
const [formUsername, setFormUsername] = useState(defaultForm.username);
|
|
const [formAuthMethod, setFormAuthMethod] = useState<SshAuthMethod>(defaultForm.authMethod);
|
|
const [formPrivateKeyPath, setFormPrivateKeyPath] = useState(defaultForm.privateKeyPath);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setFormName(defaultForm.name);
|
|
setFormHost(defaultForm.host);
|
|
setFormPort(defaultForm.port);
|
|
setFormUsername(defaultForm.username);
|
|
setFormAuthMethod(defaultForm.authMethod);
|
|
setFormPrivateKeyPath(defaultForm.privateKeyPath);
|
|
}, []);
|
|
|
|
const loadProfiles = useCallback(async () => {
|
|
try {
|
|
const config = await api.config.get();
|
|
// AppConfig type doesn't include ssh field, but ConfigManager returns it at runtime
|
|
const loaded = config.ssh;
|
|
setProfiles(loaded?.profiles ?? []);
|
|
} catch (error) {
|
|
console.error('[WorkspaceSection] Failed to load profiles:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadProfiles();
|
|
}, [loadProfiles]);
|
|
|
|
// Populate form when editing starts
|
|
useEffect(() => {
|
|
if (editingId) {
|
|
const profile = profiles.find((p) => p.id === editingId);
|
|
if (profile) {
|
|
setFormName(profile.name);
|
|
setFormHost(profile.host);
|
|
setFormPort(String(profile.port));
|
|
setFormUsername(profile.username);
|
|
setFormAuthMethod(profile.authMethod);
|
|
setFormPrivateKeyPath(profile.privateKeyPath ?? '');
|
|
}
|
|
}
|
|
}, [editingId, profiles]);
|
|
|
|
const handleAdd = async (): Promise<void> => {
|
|
const newProfile: SshConnectionProfile = {
|
|
id: crypto.randomUUID(),
|
|
name: formName.trim(),
|
|
host: formHost.trim(),
|
|
port: parseInt(formPort, 10) || 22,
|
|
username: formUsername.trim(),
|
|
authMethod: formAuthMethod,
|
|
privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined,
|
|
};
|
|
|
|
await api.config.update('ssh', { profiles: [...profiles, newProfile] });
|
|
await loadProfiles();
|
|
resetForm();
|
|
setShowAddForm(false);
|
|
void useStore.getState().fetchAvailableContexts();
|
|
};
|
|
|
|
const handleEdit = async (): Promise<void> => {
|
|
const updatedProfiles = profiles.map((p) =>
|
|
p.id === editingId
|
|
? {
|
|
...p,
|
|
name: formName.trim(),
|
|
host: formHost.trim(),
|
|
port: parseInt(formPort, 10) || 22,
|
|
username: formUsername.trim(),
|
|
authMethod: formAuthMethod,
|
|
privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined,
|
|
}
|
|
: p
|
|
);
|
|
|
|
await api.config.update('ssh', { profiles: updatedProfiles });
|
|
await loadProfiles();
|
|
setEditingId(null);
|
|
resetForm();
|
|
void useStore.getState().fetchAvailableContexts();
|
|
};
|
|
|
|
const handleDelete = async (id: string): Promise<void> => {
|
|
const profile = profiles.find((p) => p.id === id);
|
|
if (!profile) return;
|
|
|
|
const confirmed = await confirm({
|
|
title: 'Delete Profile',
|
|
message: `Are you sure you want to delete "${profile.name}"? This cannot be undone.`,
|
|
confirmLabel: 'Delete',
|
|
variant: 'danger',
|
|
});
|
|
if (!confirmed) return;
|
|
|
|
const filtered = profiles.filter((p) => p.id !== id);
|
|
await api.config.update('ssh', { profiles: filtered });
|
|
await loadProfiles();
|
|
void useStore.getState().fetchAvailableContexts();
|
|
};
|
|
|
|
const isFormValid =
|
|
formName.trim() !== '' && formHost.trim() !== '' && formUsername.trim() !== '';
|
|
|
|
const renderForm = (onSave: () => Promise<void>, onCancel: () => void): React.JSX.Element => (
|
|
<div
|
|
className="space-y-3 rounded-md border p-4"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label
|
|
htmlFor="ws-profile-name"
|
|
className="mb-1 block text-xs"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
Name
|
|
</label>
|
|
<input
|
|
id="ws-profile-name"
|
|
type="text"
|
|
value={formName}
|
|
onChange={(e) => setFormName(e.target.value)}
|
|
placeholder="My Server"
|
|
className={inputClass}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label
|
|
htmlFor="ws-profile-host"
|
|
className="mb-1 block text-xs"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
Host
|
|
</label>
|
|
<input
|
|
id="ws-profile-host"
|
|
type="text"
|
|
value={formHost}
|
|
onChange={(e) => setFormHost(e.target.value)}
|
|
placeholder="hostname or IP"
|
|
className={inputClass}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label
|
|
htmlFor="ws-profile-port"
|
|
className="mb-1 block text-xs"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
Port
|
|
</label>
|
|
<input
|
|
id="ws-profile-port"
|
|
type="text"
|
|
value={formPort}
|
|
onChange={(e) => setFormPort(e.target.value)}
|
|
placeholder="22"
|
|
className={inputClass}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label
|
|
htmlFor="ws-profile-username"
|
|
className="mb-1 block text-xs"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
Username
|
|
</label>
|
|
<input
|
|
id="ws-profile-username"
|
|
type="text"
|
|
value={formUsername}
|
|
onChange={(e) => setFormUsername(e.target.value)}
|
|
placeholder="user"
|
|
className={inputClass}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- SettingsSelect is a custom dropdown without a native control */}
|
|
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
Authentication
|
|
</label>
|
|
<SettingsSelect
|
|
value={formAuthMethod}
|
|
options={authMethodOptions}
|
|
onChange={setFormAuthMethod}
|
|
fullWidth
|
|
/>
|
|
</div>
|
|
|
|
{formAuthMethod === 'privateKey' && (
|
|
<div>
|
|
<label
|
|
htmlFor="ws-profile-private-key-path"
|
|
className="mb-1 block text-xs"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
Private Key Path
|
|
</label>
|
|
<input
|
|
id="ws-profile-private-key-path"
|
|
type="text"
|
|
value={formPrivateKeyPath}
|
|
onChange={(e) => setFormPrivateKeyPath(e.target.value)}
|
|
placeholder="~/.ssh/id_rsa"
|
|
className={inputClass}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{formAuthMethod === 'password' && (
|
|
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
You will be prompted for the password when connecting.
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<button
|
|
onClick={() => void onSave()}
|
|
disabled={!isFormValid}
|
|
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors disabled:opacity-50"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
>
|
|
<Save className="size-3.5" />
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={onCancel}
|
|
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
<X className="size-3.5" />
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SettingsSectionHeader title="Workspace Profiles" />
|
|
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
Save SSH connection profiles for quick reconnection
|
|
</p>
|
|
|
|
{loading && (
|
|
<div className="flex items-center gap-2 py-4" style={{ color: 'var(--color-text-muted)' }}>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
<span className="text-sm">Loading profiles...</span>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && profiles.length === 0 && !showAddForm && (
|
|
<div
|
|
className="rounded-md border py-8 text-center"
|
|
style={{
|
|
borderColor: 'var(--color-border)',
|
|
color: 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
<Server className="mx-auto mb-2 size-8 opacity-40" />
|
|
<p className="text-sm">No saved profiles</p>
|
|
<p className="mt-1 text-xs">Add an SSH profile to connect quickly</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && (
|
|
<div className="space-y-3">
|
|
{profiles.map((profile) =>
|
|
editingId === profile.id ? (
|
|
<div key={profile.id}>
|
|
{renderForm(handleEdit, () => {
|
|
setEditingId(null);
|
|
resetForm();
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div
|
|
key={profile.id}
|
|
className="flex items-center gap-3 rounded-md border p-4"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<Server className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className="truncate text-sm font-medium"
|
|
style={{ color: 'var(--color-text)' }}
|
|
>
|
|
{profile.name}
|
|
</p>
|
|
<p className="truncate text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
{profile.username}@{profile.host}:{profile.port}
|
|
</p>
|
|
</div>
|
|
<span
|
|
className="shrink-0 rounded px-1.5 py-0.5 text-xs"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface)',
|
|
color: 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
{profile.authMethod}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => setEditingId(profile.id)}
|
|
className="shrink-0 rounded p-1 transition-colors hover:bg-surface-raised"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
<Edit2 className="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="text-xs">
|
|
Edit profile
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => void handleDelete(profile.id)}
|
|
className="shrink-0 rounded p-1 transition-colors hover:bg-surface-raised"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="text-xs">
|
|
Delete profile
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && (
|
|
<div>
|
|
{showAddForm ? (
|
|
renderForm(handleAdd, () => {
|
|
setShowAddForm(false);
|
|
resetForm();
|
|
})
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
resetForm();
|
|
setShowAddForm(true);
|
|
}}
|
|
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
color: 'var(--color-text-secondary)',
|
|
}}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Add Profile
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|