[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:
377
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
377
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* SettingsFileAssociationsTab - Manage SFTP file opener associations and behavior
|
||||
*/
|
||||
import { FileType, Pencil, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { useSftpFileAssociations } from "../../../application/state/useSftpFileAssociations";
|
||||
import { useSettingsState } from "../../../application/state/useSettingsState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import {
|
||||
SectionHeader,
|
||||
SettingCard,
|
||||
SettingsTabContent,
|
||||
SettingRow,
|
||||
Toggle,
|
||||
Select,
|
||||
} from "../settings-ui";
|
||||
|
||||
const getOpenerLabel = (
|
||||
openerType: FileOpenerType,
|
||||
systemApp: SystemAppInfo | undefined,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (openerType === 'builtin-editor') {
|
||||
return t('sftp.opener.builtInEditor');
|
||||
} else if (openerType === 'system-app' && systemApp) {
|
||||
return systemApp.name;
|
||||
}
|
||||
return openerType;
|
||||
};
|
||||
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension, getDefaultOpener, setDefaultOpener, removeDefaultOpener } = useSftpFileAssociations();
|
||||
const {
|
||||
sftpDoubleClickBehavior, setSftpDoubleClickBehavior,
|
||||
sftpAutoSync, setSftpAutoSync,
|
||||
sftpShowHiddenFiles, setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, setSftpUseCompressedUpload,
|
||||
|
||||
sftpSkipUnchanged, setSftpSkipUnchanged,
|
||||
sftpAutoOpenSidebar, setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd, setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode, setSftpDefaultViewMode,
|
||||
sftpTransferConcurrency, setSftpTransferConcurrency,
|
||||
sshTransportIdleTtlMs, setSshTransportIdleTtlMs,
|
||||
} = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const defaultOpener = getDefaultOpener();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
|
||||
|
||||
const defaultOpenerValue = useMemo(() => {
|
||||
if (!defaultOpener) return 'ask';
|
||||
if (defaultOpener.openerType === 'builtin-editor') return 'builtin-editor';
|
||||
return 'system-app';
|
||||
}, [defaultOpener]);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
|
||||
const handleSelectDefaultSystemApp = useCallback(async () => {
|
||||
setIsSelectingDefaultApp(true);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) return;
|
||||
const result = await bridge.selectApplication();
|
||||
if (result) {
|
||||
setDefaultOpener('system-app', { path: result.path, name: result.name });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setIsSelectingDefaultApp(false);
|
||||
}
|
||||
}, [setDefaultOpener]);
|
||||
|
||||
const handleDefaultOpenerChange = useCallback((value: string) => {
|
||||
if (value === 'ask') {
|
||||
removeDefaultOpener();
|
||||
return;
|
||||
}
|
||||
if (value === 'builtin-editor') {
|
||||
setDefaultOpener('builtin-editor');
|
||||
return;
|
||||
}
|
||||
void handleSelectDefaultSystemApp();
|
||||
}, [handleSelectDefaultSystemApp, removeDefaultOpener, setDefaultOpener]);
|
||||
|
||||
const handleEdit = useCallback(async (extension: string) => {
|
||||
setEditingExtension(extension);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) {
|
||||
return;
|
||||
}
|
||||
const result = await bridge.selectApplication();
|
||||
if (result) {
|
||||
setOpenerForExtension(extension, 'system-app', { path: result.path, name: result.name });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setEditingExtension(null);
|
||||
}
|
||||
}, [setOpenerForExtension]);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="file-associations">
|
||||
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-double-click"
|
||||
description={t('settings.sftp.doubleClickBehavior.desc')}
|
||||
>
|
||||
<Select
|
||||
value={sftpDoubleClickBehavior}
|
||||
options={[
|
||||
{ value: 'open', label: t('settings.sftp.doubleClickBehavior.open') },
|
||||
{ value: 'transfer', label: t('settings.sftp.doubleClickBehavior.transfer') },
|
||||
]}
|
||||
onChange={(value) => setSftpDoubleClickBehavior(value as 'open' | 'transfer')}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-default-view-mode"
|
||||
description={t('settings.sftp.defaultViewMode.desc')}
|
||||
>
|
||||
<Select
|
||||
value={sftpDefaultViewMode}
|
||||
options={[
|
||||
{ value: 'list', label: t('settings.sftp.defaultViewMode.list') },
|
||||
{ value: 'tree', label: t('settings.sftp.defaultViewMode.tree') },
|
||||
]}
|
||||
onChange={(value) => setSftpDefaultViewMode(value as 'list' | 'tree')}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-show-hidden-files"
|
||||
label={t('settings.sftp.showHiddenFiles.enable')}
|
||||
description={t('settings.sftp.showHiddenFiles.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpShowHiddenFiles} onChange={setSftpShowHiddenFiles} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.autoSync')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-auto-sync"
|
||||
label={t('settings.sftp.autoSync.enable')}
|
||||
description={t('settings.sftp.autoSync.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpAutoSync} onChange={setSftpAutoSync} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.compressedUpload')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
label={t('settings.sftp.compressedUpload.enable')}
|
||||
description={t('settings.sftp.compressedUpload.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpUseCompressedUpload} onChange={setSftpUseCompressedUpload} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.followTerminalCwd')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-follow-terminal-cwd"
|
||||
label={t('settings.sftp.followTerminalCwd.enable')}
|
||||
description={t('settings.sftp.followTerminalCwd.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpFollowTerminalCwd} onChange={setSftpFollowTerminalCwd} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-auto-open-sidebar"
|
||||
label={t('settings.sftp.autoOpenSidebar.enable')}
|
||||
description={t('settings.sftp.autoOpenSidebar.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpAutoOpenSidebar} onChange={setSftpAutoOpenSidebar} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-transfer-concurrency"
|
||||
description={t('settings.sftp.transferConcurrency.desc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={16}
|
||||
step={1}
|
||||
value={sftpTransferConcurrency}
|
||||
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
|
||||
className="w-40 accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-6 text-center tabular-nums">
|
||||
{sftpTransferConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.sftp.skipUnchanged.enable')}
|
||||
description={t('settings.sftp.skipUnchanged.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.ssh.transportIdleTtl')}
|
||||
description={t('settings.ssh.transportIdleTtl.desc')}
|
||||
>
|
||||
<Select
|
||||
value={String(sshTransportIdleTtlMs)}
|
||||
onChange={(value) => setSshTransportIdleTtlMs(Number(value))}
|
||||
options={[
|
||||
{ value: '60000', label: t('settings.ssh.transportIdleTtl.1m') },
|
||||
{ value: '300000', label: t('settings.ssh.transportIdleTtl.5m') },
|
||||
{ value: '900000', label: t('settings.ssh.transportIdleTtl.15m') },
|
||||
{ value: '1800000', label: t('settings.ssh.transportIdleTtl.30m') },
|
||||
{ value: '0', label: t('settings.ssh.transportIdleTtl.never') },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t('settings.sftp.defaultOpener')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-default-opener"
|
||||
description={t('settings.sftp.defaultOpener.desc')}
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Select
|
||||
value={defaultOpenerValue}
|
||||
options={[
|
||||
{ value: 'ask', label: t('settings.sftp.defaultOpener.ask') },
|
||||
{ value: 'builtin-editor', label: t('sftp.opener.builtInEditor') },
|
||||
{
|
||||
value: 'system-app',
|
||||
label:
|
||||
defaultOpener?.openerType === 'system-app' && defaultOpener.systemApp
|
||||
? defaultOpener.systemApp.name
|
||||
: t('settings.sftp.defaultOpener.systemApp'),
|
||||
},
|
||||
]}
|
||||
onChange={handleDefaultOpenerChange}
|
||||
className="w-56"
|
||||
disabled={isSelectingDefaultApp}
|
||||
/>
|
||||
{defaultOpener?.openerType === 'system-app' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleSelectDefaultSystemApp()}
|
||||
disabled={isSelectingDefaultApp}
|
||||
>
|
||||
{t('settings.sftp.defaultOpener.systemApp')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader
|
||||
title={t('settings.sftpFileAssociations.title')}
|
||||
anchorId="sftp-file-associations-list"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground -mt-3 mb-1">
|
||||
{t('settings.sftpFileAssociations.desc')}
|
||||
</p>
|
||||
|
||||
{associations.length === 0 ? (
|
||||
<SettingCard className="py-12">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<FileType size={48} strokeWidth={1} className="mb-4 opacity-50" />
|
||||
<p className="text-sm">{t('settings.sftpFileAssociations.noAssociations')}</p>
|
||||
</div>
|
||||
</SettingCard>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-medium">
|
||||
{t('settings.sftpFileAssociations.extension')}
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium">
|
||||
{t('settings.sftpFileAssociations.application')}
|
||||
</th>
|
||||
<th className="text-right px-4 py-2 font-medium w-28">
|
||||
{/* Actions */}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{associations.map(({ extension, openerType, systemApp }) => (
|
||||
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{openerType === 'system-app' && systemApp ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">{systemApp.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{systemApp.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
getOpenerLabel(openerType, systemApp, t)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(extension)}
|
||||
disabled={editingExtension === extension}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(extension)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user