Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7355e29b89 | ||
|
|
64686cc237 | ||
|
|
d65440ace7 | ||
|
|
2dbeddd9aa | ||
|
|
4758345448 | ||
|
|
4d3fa93083 | ||
|
|
2746aae274 | ||
|
|
a7b22b3580 | ||
|
|
a66fcdba02 | ||
|
|
73c95fa08e | ||
|
|
3337cd620e | ||
|
|
97bd105564 | ||
|
|
554c43dfa8 | ||
|
|
c678f36504 | ||
|
|
f40a3f075b | ||
|
|
bb40ab464e | ||
|
|
4977add389 | ||
|
|
2d14655af4 | ||
|
|
025df8788b | ||
|
|
9e6d110766 | ||
|
|
347d0a445b | ||
|
|
e8be0d72de | ||
|
|
ce34f1bba8 | ||
|
|
9f4272f83c | ||
|
|
c158d52dd5 | ||
|
|
ec8dba360c | ||
|
|
8b5cc5c302 | ||
|
|
bae0c078f5 | ||
|
|
e0cda4dc5a | ||
|
|
4c0fc897a0 | ||
|
|
9ba150de82 | ||
|
|
a78647a2e8 | ||
|
|
4b7812d27f | ||
|
|
62b3cf658e | ||
|
|
74401a2084 | ||
|
|
44d25c10e1 | ||
|
|
d67c458730 | ||
|
|
44e8167300 | ||
|
|
02b16dee9b | ||
|
|
adaa8ee524 | ||
|
|
f429eb8f28 | ||
|
|
eaae884cd7 | ||
|
|
363f0ea87f | ||
|
|
b5533a73b6 | ||
|
|
6353f2c58a | ||
|
|
7e14f73769 | ||
|
|
60c2687144 | ||
|
|
7a1597bdc1 | ||
|
|
d5ae6e5cba | ||
|
|
a46080a378 | ||
|
|
f0972cc6c1 | ||
|
|
1442b42d66 | ||
|
|
24f1dc3f36 | ||
|
|
b0251e1eaf | ||
|
|
f55a1a4c15 | ||
|
|
d4b64d564b | ||
|
|
64d3b1f26a | ||
|
|
f6148d3578 | ||
|
|
c4d6d999c1 | ||
|
|
2ca5c730b8 | ||
|
|
b3a2063ca4 | ||
|
|
e6f2da48a7 | ||
|
|
a9fad5295c | ||
|
|
41822838f1 | ||
|
|
f98c578761 | ||
|
|
449d63ca3e | ||
|
|
f6f0d0ead1 | ||
|
|
dbfd50a8e0 | ||
|
|
17ffe5d1ee | ||
|
|
394cd539b3 | ||
|
|
1289223523 | ||
|
|
1d29167b97 | ||
|
|
143f6d993e | ||
|
|
7ee45ed7aa | ||
|
|
fb7b0aee86 | ||
|
|
54cd97d3f1 | ||
|
|
0bb876ea32 | ||
|
|
77a80f6dcb | ||
|
|
0989328afa | ||
|
|
c8621960d5 | ||
|
|
8fddc63777 | ||
|
|
185143cedc | ||
|
|
5ec91ea89d | ||
|
|
77c700e666 | ||
|
|
9590d3a67d | ||
|
|
2de37c3d53 | ||
|
|
994b5d1325 | ||
|
|
5bdd187365 | ||
|
|
c1959adbf6 | ||
|
|
6196c6e3c3 | ||
|
|
7980a62d55 | ||
|
|
52b18d825d | ||
|
|
70a172216a | ||
|
|
24edf6e1df | ||
|
|
a4abcab019 | ||
|
|
ca91483d01 | ||
|
|
dfcdcda189 | ||
|
|
7514f2eae3 | ||
|
|
951d1307cd | ||
|
|
0c14aed55a | ||
|
|
2fde490ee7 | ||
|
|
b6d0a3c698 | ||
|
|
5b2cf535e5 | ||
|
|
3c8ff48b4e | ||
|
|
5dcddfd0ff | ||
|
|
95bc7f018c | ||
|
|
7dee34f7d8 | ||
|
|
9b9cbb6068 | ||
|
|
940e49d2db | ||
|
|
bb25285349 | ||
|
|
be44f38911 | ||
|
|
4749bef906 | ||
|
|
797c607b0a | ||
|
|
7e3e4ce3b8 | ||
|
|
929d7dbe74 | ||
|
|
33313f71fb | ||
|
|
266e9f637c | ||
|
|
4d03c469e8 | ||
|
|
7846c5e046 | ||
|
|
a03df92dd1 | ||
|
|
a855323912 | ||
|
|
1958648f63 | ||
|
|
e830b9362a |
@@ -3,7 +3,17 @@
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)"
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh pr list:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\n\n- Bundle folder uploads as single tasks showing aggregate progress\n- Add unique file transfer IDs for proper cancellation tracking\n- Fix cancel button to call cancelExternalUpload for external uploads\n- Improve backend cancel detection using cancelled flag instead of error message\n- Use SSH exec with rm -rf for fast folder deletion on remote servers\n- Add FolderUp icon for folder upload tasks in transfer queue\n- Add i18n key for upload cancelled message\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git push:*)",
|
||||
"Bash(gh pr create --title \"feat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\" --body \"$\\(cat <<''EOF''\n## Summary\n\n- **Bundle folder uploads as single tasks** - When uploading a folder from computer, show it as one aggregated task with total progress instead of individual files\n- **Fix cancel upload** - Properly cancel external uploads by calling the correct cancel function and using unique file transfer IDs for backend tracking\n- **Fast folder deletion** - Use SSH exec with `rm -rf` command for remote folder deletion instead of slow recursive SFTP rmdir\n- **UI improvements** - Add FolderUp icon for folder upload tasks, add cancelled status toast message\n\n## Changes\n\n### Bundle folder uploads\n- Added `detectRootFolders` helper to group entries by root folder\n- Create single bundled task per folder with aggregate byte count\n- Track progress across all files in the bundle\n\n### Fix cancel upload\n- Each file now uses unique `fileTransferId` for backend cancellation tracking\n- Added `activeFileTransferIdsRef` to track all active uploads\n- Modified `cancelExternalUpload` to cancel all active file uploads\n- Backend now checks `uploadState.cancelled` flag instead of just error message\n- Frontend catch block checks `cancelUploadRef.current` to break out of loop\n\n### Fast folder deletion\n- Added `execSshCommand` helper function in sftpBridge.cjs\n- Uses `client.client` \\(underlying ssh2 Client\\) to execute `rm -rf` command\n- Falls back to SFTP rmdir if SSH exec fails\n\n## Test plan\n- [ ] Drag a folder from computer to SFTP pane - should show as single task with aggregate progress\n- [ ] Click cancel button during folder upload - should stop immediately without errors\n- [ ] Delete a large folder on remote server - should complete quickly using rm -rf\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
104
.github/scripts/generate-release-note.js
vendored
Normal file
104
.github/scripts/generate-release-note.js
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Determine version priority:
|
||||
// 1. VERSION env variable
|
||||
// 2. Valid version tag (v1.2.3 format)
|
||||
// 3. Short commit ID (first 7 chars of GITHUB_SHA)
|
||||
// 4. package.json version as fallback
|
||||
function getVersion() {
|
||||
if (process.env.VERSION) {
|
||||
return process.env.VERSION;
|
||||
}
|
||||
|
||||
const refName = process.env.GITHUB_REF_NAME;
|
||||
// Check if refName is a valid version tag (e.g., v1.2.3)
|
||||
if (refName && /^v\d+\.\d+\.\d+/.test(refName)) {
|
||||
return refName.replace(/^v/, '');
|
||||
}
|
||||
|
||||
// Use short commit ID
|
||||
const sha = process.env.GITHUB_SHA;
|
||||
if (sha) {
|
||||
return sha.substring(0, 7);
|
||||
}
|
||||
|
||||
// Fall back to package.json version
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
return pkg.version;
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
const version = getVersion();
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'binaricat/netcatty';
|
||||
// For tag releases, use the tag; for workflow_dispatch, create a tag from version
|
||||
const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.GITHUB_REF_NAME))
|
||||
? process.env.GITHUB_REF_NAME
|
||||
: `v${version}`;
|
||||
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
|
||||
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
x64: `Netcatty-${version}-mac-x64.dmg`
|
||||
},
|
||||
win: {
|
||||
x64: `Netcatty-${version}-win-x64.exe`,
|
||||
arm64: `Netcatty-${version}-win-arm64.exe`
|
||||
},
|
||||
linux: {
|
||||
appimage: {
|
||||
x64: `Netcatty-${version}-linux-x64.AppImage`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.AppImage`
|
||||
},
|
||||
deb: {
|
||||
x64: `Netcatty-${version}-linux-x64.deb`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.deb`
|
||||
},
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.rpm`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const badges = {
|
||||
win: {
|
||||
setup_x64: `[](${baseUrl}/${files.win.x64})`,
|
||||
setup_arm64: `[](${baseUrl}/${files.win.arm64})`
|
||||
},
|
||||
mac: {
|
||||
apple_silicon: `[](${baseUrl}/${files.mac.arm64})`,
|
||||
intel: `[](${baseUrl}/${files.mac.x64})`
|
||||
},
|
||||
linux: {
|
||||
appimage_x64: `[](${baseUrl}/${files.linux.appimage.x64})`,
|
||||
appimage_arm64: `[](${baseUrl}/${files.linux.appimage.arm64})`,
|
||||
deb_x64: `[](${baseUrl}/${files.linux.deb.x64})`,
|
||||
deb_arm64: `[](${baseUrl}/${files.linux.deb.arm64})`,
|
||||
rpm_x64: `[](${baseUrl}/${files.linux.rpm.x64})`,
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`
|
||||
}
|
||||
};
|
||||
|
||||
const content = `
|
||||
## Download based on your OS:
|
||||
|
||||
| OS | Download |
|
||||
| :--- | :--- |
|
||||
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
|
||||
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
|
||||
`;
|
||||
|
||||
fs.writeFileSync('release_notes.md', content);
|
||||
console.log('Generated release_notes.md');
|
||||
27
.github/workflows/build.yml
vendored
27
.github/workflows/build.yml
vendored
@@ -37,11 +37,16 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
# Tag release: use version from tag
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
# workflow_dispatch: use short commit ID
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
@@ -70,15 +75,12 @@ jobs:
|
||||
name: netcatty-${{ matrix.os }}
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.exe
|
||||
release/*.msi
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.tar.gz
|
||||
release/*.blockmap
|
||||
release/latest*.yml
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
@@ -101,20 +103,23 @@ jobs:
|
||||
- name: List artifacts
|
||||
run: ls -la artifacts/
|
||||
|
||||
- name: Generate Release Body
|
||||
run: node .github/scripts/generate-release-note.js
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: release_notes.md
|
||||
files: |
|
||||
artifacts/*.dmg
|
||||
artifacts/*.zip
|
||||
artifacts/*.exe
|
||||
artifacts/*.msi
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.tar.gz
|
||||
artifacts/*.blockmap
|
||||
artifacts/latest*.yml
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
42
.github/workflows/sync.yml
vendored
Normal file
42
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Sync Upstream
|
||||
|
||||
env:
|
||||
UPSTREAM_URL: "https://github.com/binaricat/Netcatty.git"
|
||||
UPSTREAM_BRANCH: "main"
|
||||
TARGET_BRANCH: "main"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Run daily at midnight
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Merge Upstream
|
||||
run: |
|
||||
echo "Adding upstream remote..."
|
||||
git remote add upstream ${{ env.UPSTREAM_URL }}
|
||||
git fetch upstream ${{ env.UPSTREAM_BRANCH }}
|
||||
|
||||
echo "Merging upstream/${{ env.UPSTREAM_BRANCH }} into ${{ env.TARGET_BRANCH }}..."
|
||||
# This will fail if there are conflicts, which is the desired behavior (notify user via failure)
|
||||
git merge upstream/${{ env.UPSTREAM_BRANCH }} --no-edit
|
||||
|
||||
echo "Pushing changes..."
|
||||
git push origin ${{ env.TARGET_BRANCH }}
|
||||
122
App.tsx
122
App.tsx
@@ -8,6 +8,7 @@ import { useUpdateCheck } from './application/state/useUpdateCheck';
|
||||
import { useVaultState } from './application/state/useVaultState';
|
||||
import { useWindowControls } from './application/state/useWindowControls';
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
@@ -20,6 +21,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
@@ -28,6 +30,7 @@ import type { TerminalLayer as TerminalLayerComponent } from './components/Termi
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
initializeUIFonts();
|
||||
|
||||
// Visibility container for VaultView - isolates isActive subscription
|
||||
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -153,6 +156,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -167,6 +172,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
} = settings;
|
||||
|
||||
const {
|
||||
@@ -237,6 +245,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
} = useSessionState();
|
||||
|
||||
// isMacClient is used for window controls styling
|
||||
@@ -288,10 +297,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
[keys]
|
||||
);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
hosts,
|
||||
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
keys: portForwardingKeys,
|
||||
});
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
@@ -337,6 +352,76 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Passphrase request event listener for encrypted SSH keys
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseRequest) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseRequest((request) => {
|
||||
console.log('[App] Passphrase request received:', request);
|
||||
setPassphraseQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
keyPath: request.keyPath,
|
||||
keyName: request.keyName,
|
||||
hostname: request.hostname,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase submit
|
||||
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
void bridge.respondPassphrase(requestId, passphrase, false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase cancel
|
||||
const handlePassphraseCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
// Cancel = stop the entire passphrase flow
|
||||
void bridge.respondPassphrase(requestId, '', true);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase skip (skip this key, continue with others)
|
||||
const handlePassphraseSkip = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphraseSkip) {
|
||||
// Skip = skip this key but continue asking for others
|
||||
void bridge.respondPassphraseSkip(requestId);
|
||||
} else if (bridge?.respondPassphrase) {
|
||||
// Fallback for older API
|
||||
void bridge.respondPassphrase(requestId, '', false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase timeout (request expired on backend)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseTimeout) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseTimeout((event) => {
|
||||
console.log('[App] Passphrase request timed out:', event.requestId);
|
||||
// Remove from queue - the modal will close automatically
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
|
||||
// Show a toast notification to inform user
|
||||
toast.error('Passphrase request timed out. Please try connecting again.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -614,7 +699,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
)
|
||||
: hosts;
|
||||
return filtered.slice(0, 8);
|
||||
return filtered;
|
||||
}, [hosts, quickSearch, isQuickSwitcherOpen]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
@@ -751,10 +836,32 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
terminalData: data,
|
||||
});
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
|
||||
|
||||
// Auto-save session log if enabled
|
||||
if (sessionLogsEnabled && sessionLogsDir && data) {
|
||||
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.autoSaveSessionLog) {
|
||||
bridge.autoSaveSessionLog({
|
||||
terminalData: data,
|
||||
hostLabel: matchingLog.hostLabel,
|
||||
hostname: matchingLog.hostname,
|
||||
hostId: matchingLog.hostId,
|
||||
startTime: matchingLog.startTime,
|
||||
format: sessionLogsFormat,
|
||||
directory: sessionLogsDir,
|
||||
}).then(result => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
|
||||
}).catch(err => {
|
||||
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
|
||||
}
|
||||
}, [sessions, connectionLogs, updateConnectionLog]);
|
||||
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
|
||||
|
||||
// Check if host has multiple protocols enabled
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
@@ -831,6 +938,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySession}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -1048,6 +1156,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Passphrase Modal for encrypted SSH keys */}
|
||||
<PassphraseModal
|
||||
request={passphraseQueue[0] || null}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
|
||||
### 📁 SFTP
|
||||
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
|
||||
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
|
||||
- **ドラッグ&ドロップ** アップロードおよびダウンロード
|
||||
- **ドラッグ&ドロップ**ファイル転送
|
||||
- **キュー管理**でバッチ転送
|
||||
- **進捗追跡**、転送速度表示
|
||||
@@ -278,11 +280,11 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ステータス |
|
||||
|------------------|----------------|------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
|
||||
| **macOS** | Intel | ✅ サポート |
|
||||
| **Windows** | x64 | ✅ サポート |
|
||||
| OS | サポート状況 |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -98,7 +98,8 @@
|
||||
|
||||
### 📁 SFTP
|
||||
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
|
||||
- **Drag & drop** file transfers
|
||||
- **Sudo Privilege Escalation** — Browse and edit root-owned files with sudo
|
||||
- **Drag & Drop** uploads and downloads
|
||||
- **Queue management** for batch transfers
|
||||
- **Progress tracking** with transfer speed
|
||||
|
||||
@@ -278,11 +279,11 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
|
||||
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
|
||||
|
||||
| Platform | Architecture | Status |
|
||||
|----------|--------------|--------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
|
||||
| **macOS** | Intel | ✅ Supported |
|
||||
| **Windows** | x64 | ✅ Supported |
|
||||
| OS | Support |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
|
||||
### 📁 SFTP
|
||||
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
|
||||
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
|
||||
- **拖放操作** —— 支持上传和下载
|
||||
- **拖放传输** 文件
|
||||
- **队列管理** 批量传输
|
||||
- **进度跟踪** 显示传输速度
|
||||
@@ -278,11 +280,11 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
|
||||
|
||||
| 平台 | 架构 | 状态 |
|
||||
|------|------|------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
|
||||
| **macOS** | Intel | ✅ 支持 |
|
||||
| **Windows** | x64 | ✅ 支持 |
|
||||
| 操作系统 | 支持情况 |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
|
||||
@@ -77,6 +77,24 @@ const en: Messages = {
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
|
||||
'settings.sessionLogs.autoSave': 'Auto-Save',
|
||||
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
|
||||
'settings.sessionLogs.directory': 'Save Directory',
|
||||
'settings.sessionLogs.noDirectory': 'No directory selected',
|
||||
'settings.sessionLogs.browse': 'Browse',
|
||||
'settings.sessionLogs.openFolder': 'Open folder',
|
||||
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
|
||||
'settings.sessionLogs.format': 'Log Format',
|
||||
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
|
||||
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
'settings.application.reportProblem': 'Report a problem',
|
||||
@@ -119,6 +137,8 @@ const en: Messages = {
|
||||
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
@@ -201,6 +221,18 @@ const en: Messages = {
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
|
||||
'settings.terminal.serverStats.seconds': 'seconds',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': 'Rendering',
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
@@ -299,7 +331,14 @@ const en: Messages = {
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
'vault.hosts.export': 'Export',
|
||||
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.copyCredentials': 'Copy Credentials',
|
||||
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -431,6 +470,10 @@ const en: Messages = {
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Go to home',
|
||||
'sftp.folderName': 'Folder name',
|
||||
'sftp.folderName.placeholder': 'Enter folder name',
|
||||
@@ -441,6 +484,7 @@ const en: Messages = {
|
||||
'sftp.rename.newName': 'New name',
|
||||
'sftp.rename.placeholder': 'Enter new name',
|
||||
'sftp.confirm.deleteOne': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.single': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
|
||||
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
|
||||
'sftp.error.loadFailed': 'Failed to load directory',
|
||||
@@ -553,6 +597,7 @@ const en: Messages = {
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Uploading {current} of {total} files...',
|
||||
'sftp.upload.uploading': 'Uploading...',
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
@@ -614,6 +659,8 @@ const en: Messages = {
|
||||
'hostDetails.sftp.sudo': 'Sudo Mode',
|
||||
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
|
||||
'hostDetails.sftp.encoding': 'Filename Encoding',
|
||||
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
|
||||
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
|
||||
'hostDetails.group.placeholder': 'Parent Group',
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
@@ -622,6 +669,9 @@ const en: Messages = {
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
'hostDetails.password.show': 'Show password',
|
||||
'hostDetails.password.hide': 'Hide password',
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate',
|
||||
@@ -726,7 +776,7 @@ const en: Messages = {
|
||||
'logs.empty.title': 'No Connection Logs',
|
||||
'logs.empty.desc':
|
||||
'Your connection history will appear here when you connect to hosts or open local terminals.',
|
||||
'logs.showing': 'Showing {limit} of {total} logs.',
|
||||
'logs.loadMore': 'Load {count} more logs',
|
||||
'logs.ongoing': 'ongoing',
|
||||
'logs.localTerminal': 'Local Terminal',
|
||||
'logs.action.save': 'Save',
|
||||
@@ -737,6 +787,7 @@ const en: Messages = {
|
||||
'logView.customizeAppearance': 'Customize appearance',
|
||||
'logView.appearance': 'Appearance',
|
||||
'logView.readOnly': 'Read-only',
|
||||
'logView.export': 'Export',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
@@ -754,6 +805,20 @@ const en: Messages = {
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
'terminal.serverStats.memBuffers': 'Buffers',
|
||||
'terminal.serverStats.memCached': 'Cache',
|
||||
'terminal.serverStats.memFree': 'Free',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
'terminal.serverStats.network': 'Network Speed',
|
||||
'terminal.serverStats.networkDetails': 'Network Interfaces',
|
||||
'terminal.serverStats.noData': 'No data available',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
@@ -1045,6 +1110,7 @@ const en: Messages = {
|
||||
'tabs.closeLogViewAria': 'Close log view',
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
@@ -1150,6 +1216,16 @@ const en: Messages = {
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
'passphrase.desc': 'Enter the passphrase for {keyName}',
|
||||
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
|
||||
'passphrase.label': 'Passphrase',
|
||||
'passphrase.keyPath': 'Key',
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -65,6 +65,24 @@ const zhCN: Messages = {
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
|
||||
'settings.sessionLogs.autoSave': '自动保存',
|
||||
'settings.sessionLogs.enableAutoSave': '启用自动保存',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
|
||||
'settings.sessionLogs.directory': '保存目录',
|
||||
'settings.sessionLogs.noDirectory': '未选择目录',
|
||||
'settings.sessionLogs.browse': '浏览',
|
||||
'settings.sessionLogs.openFolder': '打开文件夹',
|
||||
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
|
||||
'settings.sessionLogs.format': '日志格式',
|
||||
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
|
||||
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
|
||||
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
'settings.application.reportProblem': '反馈问题',
|
||||
@@ -106,6 +124,8 @@ const zhCN: Messages = {
|
||||
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
@@ -182,7 +202,14 @@ const zhCN: Messages = {
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
'vault.hosts.export': '导出',
|
||||
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.copyCredentials': '复制账密信息',
|
||||
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -296,6 +323,10 @@ const zhCN: Messages = {
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
'sftp.encoding.auto': '自动',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
@@ -306,6 +337,7 @@ const zhCN: Messages = {
|
||||
'sftp.rename.newName': '新名称',
|
||||
'sftp.rename.placeholder': '输入新名称',
|
||||
'sftp.confirm.deleteOne': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.single': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
|
||||
'sftp.error.loadFailed': '加载目录失败',
|
||||
@@ -374,6 +406,8 @@ const zhCN: Messages = {
|
||||
'hostDetails.sftp.sudo': 'Sudo 提权模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
|
||||
'hostDetails.sftp.encoding': '文件名编码',
|
||||
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
|
||||
'hostDetails.label.placeholder': '名称(例如:Production Server)',
|
||||
'hostDetails.group.placeholder': '父级 Group',
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
@@ -382,6 +416,9 @@ const zhCN: Messages = {
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
'hostDetails.password.show': '显示密码',
|
||||
'hostDetails.password.hide': '隐藏密码',
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书',
|
||||
@@ -457,7 +494,7 @@ const zhCN: Messages = {
|
||||
'logs.table.saved': '收藏',
|
||||
'logs.empty.title': '暂无连接日志',
|
||||
'logs.empty.desc': '当你连接主机或打开本地终端后,这里会显示连接历史。',
|
||||
'logs.showing': '显示 {limit}/{total} 条日志。',
|
||||
'logs.loadMore': '加载更多 ({count} 条)',
|
||||
'logs.ongoing': '进行中',
|
||||
'logs.localTerminal': '本地终端',
|
||||
'logs.action.save': '收藏',
|
||||
@@ -468,6 +505,7 @@ const zhCN: Messages = {
|
||||
'logView.customizeAppearance': '自定义外观',
|
||||
'logView.appearance': '外观',
|
||||
'logView.readOnly': '只读',
|
||||
'logView.export': '导出',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
@@ -485,6 +523,20 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
'terminal.serverStats.memBuffers': '缓冲区',
|
||||
'terminal.serverStats.memCached': '缓存',
|
||||
'terminal.serverStats.memFree': '空闲',
|
||||
'terminal.serverStats.topProcesses': '内存占用前十进程',
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
'terminal.serverStats.network': '网络速度',
|
||||
'terminal.serverStats.networkDetails': '网络接口',
|
||||
'terminal.serverStats.noData': '暂无数据',
|
||||
'terminal.search.placeholder': '搜索…',
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
@@ -799,6 +851,7 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.uploading': '正在上传...',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
@@ -893,6 +946,18 @@ const zhCN: Messages = {
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
|
||||
'settings.terminal.serverStats.seconds': '秒',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
@@ -1034,6 +1099,7 @@ const zhCN: Messages = {
|
||||
'tabs.closeLogViewAria': '关闭日志视图',
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
@@ -1139,6 +1205,16 @@ const zhCN: Messages = {
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
'passphrase.desc': '请输入 {keyName} 的密码',
|
||||
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
|
||||
'passphrase.label': '密码',
|
||||
'passphrase.keyPath': '密钥',
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
34
application/state/sftp/errors.ts
Normal file
34
application/state/sftp/errors.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const isSessionError = (err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a fatal error that should stop the entire upload.
|
||||
* This includes session errors AND target directory deletion errors.
|
||||
*/
|
||||
export const isFatalUploadError = (errorMessage: string): boolean => {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
// Session-related errors
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("connection") ||
|
||||
msg.includes("disconnected") ||
|
||||
// Target directory was deleted during upload
|
||||
msg.includes("no such file") ||
|
||||
msg.includes("enoent") ||
|
||||
msg.includes("does not exist") ||
|
||||
msg.includes("write stream error") ||
|
||||
// Directory was removed
|
||||
msg.includes("directory not found") ||
|
||||
msg.includes("not a directory")
|
||||
);
|
||||
};
|
||||
454
application/state/sftp/mockLocalFiles.ts
Normal file
454
application/state/sftp/mockLocalFiles.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { SftpFileEntry } from "../../../domain/models";
|
||||
import { formatDate } from "./utils";
|
||||
|
||||
// Mock local file data for development (when backend is not available)
|
||||
export function buildMockLocalFiles(path: string): SftpFileEntry[] {
|
||||
// Normalize path for matching (handle both Windows and Unix paths)
|
||||
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
|
||||
|
||||
const mockData: Record<string, SftpFileEntry[]> = {
|
||||
// Unix-style paths
|
||||
"/": [
|
||||
{
|
||||
name: "Users",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Applications",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "System",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"/Users": [
|
||||
{
|
||||
name: "damao",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Shared",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"/Users/damao": [
|
||||
{
|
||||
name: "Desktop",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "Documents",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "Downloads",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Pictures",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 900000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 900000),
|
||||
},
|
||||
],
|
||||
// Windows-style paths (normalized to forward slashes for matching)
|
||||
"C:": [
|
||||
{
|
||||
name: "Users",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Program Files",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "Windows",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"C:/Users": [
|
||||
{
|
||||
name: "damao",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Public",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Default",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao": [
|
||||
{
|
||||
name: "Desktop",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "Documents",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "Downloads",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Pictures",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 900000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 900000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Desktop": [
|
||||
{
|
||||
name: "Netcatty",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 300000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 300000),
|
||||
},
|
||||
{
|
||||
name: "notes.txt",
|
||||
type: "file",
|
||||
size: 2048,
|
||||
sizeFormatted: "2 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "screenshot.png",
|
||||
type: "file",
|
||||
size: 1048576,
|
||||
sizeFormatted: "1 MB",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Desktop/Netcatty": [
|
||||
{
|
||||
name: "src",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 600000),
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "file",
|
||||
size: 1536,
|
||||
sizeFormatted: "1.5 KB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "file",
|
||||
size: 4096,
|
||||
sizeFormatted: "4 KB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "tsconfig.json",
|
||||
type: "file",
|
||||
size: 512,
|
||||
sizeFormatted: "512 Bytes",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Documents": [
|
||||
{
|
||||
name: "Work",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Personal",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "report.pdf",
|
||||
type: "file",
|
||||
size: 2097152,
|
||||
sizeFormatted: "2 MB",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Downloads": [
|
||||
{
|
||||
name: "installer.exe",
|
||||
type: "file",
|
||||
size: 52428800,
|
||||
sizeFormatted: "50 MB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "archive.zip",
|
||||
type: "file",
|
||||
size: 10485760,
|
||||
sizeFormatted: "10 MB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "document.pdf",
|
||||
type: "file",
|
||||
size: 524288,
|
||||
sizeFormatted: "512 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Projects": [
|
||||
{
|
||||
name: "webapp",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "scripts",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Desktop": [
|
||||
{
|
||||
name: "Netcatty",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 300000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 300000),
|
||||
},
|
||||
{
|
||||
name: "notes.txt",
|
||||
type: "file",
|
||||
size: 2048,
|
||||
sizeFormatted: "2 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "screenshot.png",
|
||||
type: "file",
|
||||
size: 1048576,
|
||||
sizeFormatted: "1 MB",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Desktop/Netcatty": [
|
||||
{
|
||||
name: "src",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 600000),
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "file",
|
||||
size: 1536,
|
||||
sizeFormatted: "1.5 KB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "file",
|
||||
size: 4096,
|
||||
sizeFormatted: "4 KB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "tsconfig.json",
|
||||
type: "file",
|
||||
size: 512,
|
||||
sizeFormatted: "512 Bytes",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Documents": [
|
||||
{
|
||||
name: "Work",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Personal",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "report.pdf",
|
||||
type: "file",
|
||||
size: 2097152,
|
||||
sizeFormatted: "2 MB",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Downloads": [
|
||||
{
|
||||
name: "installer.exe",
|
||||
type: "file",
|
||||
size: 52428800,
|
||||
sizeFormatted: "50 MB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "archive.zip",
|
||||
type: "file",
|
||||
size: 10485760,
|
||||
sizeFormatted: "10 MB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "document.pdf",
|
||||
type: "file",
|
||||
size: 524288,
|
||||
sizeFormatted: "512 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Projects": [
|
||||
{
|
||||
name: "webapp",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "scripts",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
};
|
||||
return mockData[normPath] || [];
|
||||
}
|
||||
55
application/state/sftp/types.ts
Normal file
55
application/state/sftp/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface SftpPane {
|
||||
id: string;
|
||||
connection: SftpConnection | null;
|
||||
files: SftpFileEntry[];
|
||||
loading: boolean;
|
||||
reconnecting: boolean;
|
||||
error: string | null;
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
export interface SftpSideTabs {
|
||||
tabs: SftpPane[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
// Constants for empty placeholder pane IDs
|
||||
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
|
||||
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
|
||||
|
||||
export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
id: id || crypto.randomUUID(),
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
});
|
||||
|
||||
// File watch event types
|
||||
export interface FileWatchSyncedEvent {
|
||||
watchId: string;
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
export interface FileWatchErrorEvent {
|
||||
watchId: string;
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SftpStateOptions {
|
||||
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
|
||||
onFileWatchError?: (event: FileWatchErrorEvent) => void;
|
||||
}
|
||||
427
application/state/sftp/useSftpConnections.ts
Normal file
427
application/state/sftp/useSftpConnections.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
leftTabs: { tabs: SftpPane[] };
|
||||
rightTabs: { tabs: SftpPane[] };
|
||||
leftPane: SftpPane;
|
||||
rightPane: SftpPane;
|
||||
setLeftTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
|
||||
setRightTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (prev: SftpPane) => SftpPane) => void;
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
createEmptyPane: (id?: string) => SftpPane;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
}
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
leftTabs,
|
||||
rightTabs: _rightTabs,
|
||||
leftPane,
|
||||
rightPane,
|
||||
setLeftTabs,
|
||||
setRightTabs,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local") => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
|
||||
if (!sideTabs.activeTabId) {
|
||||
const newPane = createEmptyPane();
|
||||
activeTabId = newPane.id;
|
||||
setTabs((prev) => ({
|
||||
tabs: [...prev.tabs, newPane],
|
||||
activeTabId: newPane.id,
|
||||
}));
|
||||
} else {
|
||||
activeTabId = sideTabs.activeTabId;
|
||||
}
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
const connectionId = `${side}-${Date.now()}`;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
|
||||
const currentPane = getActivePane(side);
|
||||
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
|
||||
// This ensures proper auto-detection works and respects host-level encoding settings
|
||||
const filenameEncoding: SftpFilenameEncoding =
|
||||
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
|
||||
|
||||
if (currentPane?.connection) {
|
||||
clearCacheForConnection(currentPane.connection.id);
|
||||
}
|
||||
if (currentPane?.connection && !currentPane.connection.isLocal) {
|
||||
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
|
||||
if (oldSftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(oldSftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (host === "local") {
|
||||
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
|
||||
if (!homeDir) {
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
}
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
status: "connected",
|
||||
currentPath: homeDir,
|
||||
homeDir,
|
||||
};
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection,
|
||||
loading: true,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
try {
|
||||
const files = await listLocalFiles(homeDir);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
isLocal: false,
|
||||
status: "connecting",
|
||||
currentPath: "/",
|
||||
};
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection,
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
error: null,
|
||||
files: prev.reconnecting ? prev.files : [],
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
try {
|
||||
const credentials = getHostCredentials(host);
|
||||
const bridge = netcattyBridge.get();
|
||||
const openSftp = bridge?.openSftp;
|
||||
if (!openSftp) throw new Error("SFTP bridge unavailable");
|
||||
|
||||
const isAuthError = (err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("authentication") ||
|
||||
msg.includes("auth") ||
|
||||
msg.includes("password") ||
|
||||
msg.includes("permission denied")
|
||||
);
|
||||
};
|
||||
|
||||
const hasKey = !!credentials.privateKey;
|
||||
const hasPassword = !!credentials.password;
|
||||
|
||||
let sftpId: string | undefined;
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
};
|
||||
if (!credentials.sudo) {
|
||||
keyFirstCredentials.password = undefined;
|
||||
}
|
||||
sftpId = await openSftp(keyFirstCredentials);
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
privateKey: undefined,
|
||||
certificate: undefined,
|
||||
publicKey: undefined,
|
||||
keyId: undefined,
|
||||
keySource: undefined,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
|
||||
let startPath = "/";
|
||||
const statSftp = netcattyBridge.get()?.statSftp;
|
||||
if (statSftp) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await statSftp(sftpId, candidate, filenameEncoding);
|
||||
if (stat?.type === "directory") {
|
||||
startPath = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (credentials.username === "root") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
} else if (credentials.username) {
|
||||
try {
|
||||
const homeFiles = await netcattyBridge.get()?.listSftp(
|
||||
sftpId,
|
||||
`/home/${credentials.username}`,
|
||||
filenameEncoding,
|
||||
);
|
||||
if (homeFiles) startPath = `/home/${credentials.username}`;
|
||||
} catch {
|
||||
// Fall through to /root check
|
||||
}
|
||||
if (startPath === "/") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
reconnectingRef.current[side] = false;
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "connected",
|
||||
currentPath: startPath,
|
||||
homeDir: startPath,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
getHostCredentials,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
clearCacheForConnection,
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
],
|
||||
);
|
||||
|
||||
const initialConnectDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
|
||||
initialConnectDoneRef.current = true;
|
||||
setTimeout(() => {
|
||||
connect("left", "local");
|
||||
}, 0);
|
||||
}
|
||||
}, [connect, leftTabs.tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const attemptReconnect = async (side: "left" | "right") => {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && reconnectingRef.current[side]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (reconnectingRef.current[side]) {
|
||||
connect(side, lastHost);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (leftPane.reconnecting && reconnectingRef.current.left) {
|
||||
attemptReconnect("left");
|
||||
}
|
||||
if (rightPane.reconnecting && reconnectingRef.current.right) {
|
||||
attemptReconnect("right");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const activeTabId = sideTabs.activeTabId;
|
||||
|
||||
if (!pane || !activeTabId) return;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
if (pane.connection) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
|
||||
reconnectingRef.current[side] = false;
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal) {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (sftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
}
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
}
|
||||
}
|
||||
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, clearCacheForConnection, updateTab],
|
||||
);
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
};
|
||||
};
|
||||
63
application/state/sftp/useSftpDirectoryListing.ts
Normal file
63
application/state/sftp/useSftpDirectoryListing.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { buildMockLocalFiles } from "./mockLocalFiles";
|
||||
import { formatFileSize, formatDate } from "./utils";
|
||||
|
||||
export const useSftpDirectoryListing = () => {
|
||||
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
|
||||
return buildMockLocalFiles(path);
|
||||
}, []);
|
||||
|
||||
const listLocalFiles = useCallback(
|
||||
async (path: string): Promise<SftpFileEntry[]> => {
|
||||
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
|
||||
if (!rawFiles) {
|
||||
return getMockLocalFiles(path);
|
||||
}
|
||||
|
||||
return rawFiles.map((f) => {
|
||||
const size = parseInt(f.size) || 0;
|
||||
const lastModified = new Date(f.lastModified).getTime();
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size,
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
hidden: f.hidden,
|
||||
};
|
||||
});
|
||||
},
|
||||
[getMockLocalFiles],
|
||||
);
|
||||
|
||||
const listRemoteFiles = useCallback(
|
||||
async (sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpFileEntry[]> => {
|
||||
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path, encoding);
|
||||
if (!rawFiles) return [];
|
||||
|
||||
return rawFiles.map((f) => {
|
||||
const size = parseInt(f.size) || 0;
|
||||
const lastModified = new Date(f.lastModified).getTime();
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size,
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
};
|
||||
};
|
||||
449
application/state/sftp/useSftpExternalOperations.ts
Normal file
449
application/state/sftp/useSftpExternalOperations.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import { TransferTask, TransferStatus } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { joinPath } from "./utils";
|
||||
import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadResult,
|
||||
UploadTaskInfo,
|
||||
} from "../../../lib/uploadService";
|
||||
|
||||
// Re-export UploadResult for external usage
|
||||
export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
interface SftpExternalOperationsResult {
|
||||
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
|
||||
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
|
||||
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
|
||||
downloadToTempAndOpen: (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
|
||||
export const useSftpExternalOperations = (
|
||||
params: UseSftpExternalOperationsParams
|
||||
): SftpExternalOperationsResult => {
|
||||
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
|
||||
|
||||
// Upload controller for cancellation support
|
||||
const uploadControllerRef = useRef<UploadController | null>(null);
|
||||
|
||||
const readTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<string> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.readLocalFile) {
|
||||
const buffer = await bridge.readLocalFile(filePath);
|
||||
return new TextDecoder().decode(buffer);
|
||||
}
|
||||
throw new Error("Local file reading not supported");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
return await bridge.readSftp(sftpId, filePath, pane.filenameEncoding);
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const readBinaryFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<ArrayBuffer> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.readLocalFile) {
|
||||
return await bridge.readLocalFile(filePath);
|
||||
}
|
||||
throw new Error("Local file reading not supported");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readSftpBinary) {
|
||||
throw new Error("Binary file reading not supported");
|
||||
}
|
||||
|
||||
return await bridge.readSftpBinary(sftpId, filePath, pane.filenameEncoding);
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const writeTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string, content: string): Promise<void> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.writeLocalFile) {
|
||||
const data = new TextEncoder().encode(content);
|
||||
await bridge.writeLocalFile(filePath, data.buffer);
|
||||
return;
|
||||
}
|
||||
throw new Error("Local file writing not supported");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
await bridge.writeSftp(sftpId, filePath, content, pane.filenameEncoding);
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
throw new Error("System app opening not supported");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await bridge.openWithApplication(remotePath, appPath);
|
||||
return { localTempPath: remotePath };
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const localTempPath = await bridge.downloadSftpToTemp(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
console.log("[SFTP] File downloaded to temp", { localTempPath });
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
await bridge.registerTempFile(sftpId, localTempPath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[SFTP] Opening with application", { localTempPath, appPath });
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
console.log("[SFTP] Application launched");
|
||||
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(
|
||||
localTempPath,
|
||||
remotePath,
|
||||
sftpId,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to start file watch:", err);
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTP] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
const createUploadCallbacks = useCallback((
|
||||
connectionId: string,
|
||||
targetPath: string
|
||||
): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
if (addExternalUpload) {
|
||||
const scanningTask: TransferTask = {
|
||||
id: taskId,
|
||||
fileName: "Scanning files...",
|
||||
sourcePath: "local",
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
};
|
||||
addExternalUpload(scanningTask);
|
||||
}
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
if (dismissExternalUpload) {
|
||||
dismissExternalUpload(taskId);
|
||||
}
|
||||
},
|
||||
onTaskCreated: (task: UploadTaskInfo) => {
|
||||
if (addExternalUpload) {
|
||||
const transferTask: TransferTask = {
|
||||
id: task.id,
|
||||
fileName: task.displayName,
|
||||
sourcePath: "local",
|
||||
targetPath: joinPath(targetPath, task.fileName),
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
direction: "upload",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: task.totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
};
|
||||
addExternalUpload(transferTask);
|
||||
}
|
||||
},
|
||||
onTaskProgress: (taskId: string, progress) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
transferredBytes: progress.transferred,
|
||||
speed: progress.speed,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
|
||||
|
||||
// Create upload bridge that wraps netcattyBridge
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
writeLocalFile: bridge?.writeLocalFile,
|
||||
mkdirLocal: bridge?.mkdirLocal,
|
||||
mkdirSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.mkdirSftp) {
|
||||
await b.mkdirSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
writeSftpBinary: bridge?.writeSftpBinary,
|
||||
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
|
||||
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
|
||||
// NetcattyBridge: (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError)
|
||||
writeSftpBinaryWithProgress: bridge?.writeSftpBinaryWithProgress
|
||||
? async (sftpId, path, data, taskId, onProgress, onComplete, onError) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.writeSftpBinaryWithProgress) return undefined;
|
||||
// Pass undefined for encoding to use session default, and forward callbacks
|
||||
return b.writeSftpBinaryWithProgress(
|
||||
sftpId,
|
||||
path,
|
||||
data,
|
||||
taskId,
|
||||
undefined, // encoding - use session default
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
cancelSftpUpload: bridge?.cancelSftpUpload,
|
||||
// Stream transfer for large files (avoids loading into memory)
|
||||
startStreamTransfer: bridge?.startStreamTransfer
|
||||
? async (options, onProgress, onComplete, onError) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.startStreamTransfer) {
|
||||
return { transferId: options.transferId, error: 'Stream transfer not available' };
|
||||
}
|
||||
try {
|
||||
const result = await b.startStreamTransfer(options, onProgress, onComplete, onError);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
transferId: options.transferId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
cancelTransfer: bridge?.cancelTransfer,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uploadExternalFiles = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
const sftpId = pane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(pane.connection.id) || null;
|
||||
|
||||
if (!pane.connection.isLocal && !sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// Create a new upload controller for this upload
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
|
||||
|
||||
try {
|
||||
const results = await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: pane.connection.currentPath,
|
||||
sftpId,
|
||||
isLocal: pane.connection.isLocal,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await refresh(side);
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
|
||||
);
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
const controller = uploadControllerRef.current;
|
||||
if (controller) {
|
||||
logger.info("[SFTP] Cancelling external upload");
|
||||
await controller.cancel();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectApplication = useCallback(
|
||||
async (): Promise<{ path: string; name: string } | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) {
|
||||
return null;
|
||||
}
|
||||
return await bridge.selectApplication();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
};
|
||||
};
|
||||
27
application/state/sftp/useSftpFileWatch.ts
Normal file
27
application/state/sftp/useSftpFileWatch.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
|
||||
|
||||
export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
|
||||
|
||||
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
|
||||
options?.onFileWatchSynced?.(payload);
|
||||
});
|
||||
|
||||
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
|
||||
options?.onFileWatchError?.(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
try {
|
||||
unsubscribeSynced?.();
|
||||
unsubscribeError?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
};
|
||||
}, [options]);
|
||||
};
|
||||
75
application/state/sftp/useSftpHostCredentials.ts
Normal file
75
application/state/sftp/useSftpHostCredentials.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey } from "../../../domain/models";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => {
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
};
|
||||
},
|
||||
[hosts, identities, keys],
|
||||
);
|
||||
535
application/state/sftp/useSftpPaneActions.ts
Normal file
535
application/state/sftp/useSftpPaneActions.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
leftTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
navSeqRef: React.MutableRefObject<{ left: number; right: number }>;
|
||||
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
isSessionError: (err: unknown) => boolean;
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
rangeSelect: (side: "left" | "right", fileNames: string[]) => void;
|
||||
clearSelection: (side: "left" | "right") => void;
|
||||
selectAll: (side: "left" | "right") => void;
|
||||
setFilter: (side: "left" | "right", filter: string) => void;
|
||||
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
|
||||
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||||
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||||
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||||
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpPaneActions = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const navigateTo = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean },
|
||||
) => {
|
||||
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const activeTabId = sideTabs.activeTabId;
|
||||
|
||||
console.log("[SFTP navigateTo] state check", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
activeTabId,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection || !activeTabId) {
|
||||
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
|
||||
if (
|
||||
cached &&
|
||||
Date.now() - cached.timestamp < dirCacheTtlMs &&
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
files: cached.files,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
let files: SftpFileEntry[];
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
files = await listLocalFiles(path);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session lost. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session expired. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
}
|
||||
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[
|
||||
getActivePane,
|
||||
updateTab,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
makeCacheKey,
|
||||
dirCacheTtlMs,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
isSessionError,
|
||||
],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
error: "sftp.reconnecting.title",
|
||||
}));
|
||||
} else if (!lastHost) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.connectionLostManual",
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
|
||||
|
||||
if (!isAtRoot) {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
await navigateTo(side, parentPath);
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo],
|
||||
);
|
||||
|
||||
const openEntry = useCallback(
|
||||
async (side: "left" | "right", entry: SftpFileEntry) => {
|
||||
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
console.log("[SFTP openEntry] getActivePane result", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection) {
|
||||
console.log("[SFTP openEntry] No pane or connection, returning early");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.name === "..") {
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
|
||||
console.log("[SFTP openEntry] Navigating up from '..'", {
|
||||
currentPath,
|
||||
isAtRoot,
|
||||
isWindowsRoot: isWindowsRoot(currentPath),
|
||||
});
|
||||
|
||||
if (!isAtRoot) {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
|
||||
await navigateTo(side, parentPath);
|
||||
} else {
|
||||
console.log("[SFTP openEntry] Already at root, not navigating");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNavigableDirectory(entry)) {
|
||||
const newPath = joinPath(pane.connection.currentPath, entry.name);
|
||||
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
|
||||
await navigateTo(side, newPath);
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo],
|
||||
);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
|
||||
updateActiveTab(side, (prev) => {
|
||||
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
|
||||
if (newSelection.has(fileName)) {
|
||||
newSelection.delete(fileName);
|
||||
} else {
|
||||
newSelection.add(fileName);
|
||||
}
|
||||
return { ...prev, selectedFiles: newSelection };
|
||||
});
|
||||
},
|
||||
[updateActiveTab],
|
||||
);
|
||||
|
||||
const rangeSelect = useCallback(
|
||||
(side: "left" | "right", fileNames: string[]) => {
|
||||
const newSelection = new Set<string>();
|
||||
for (const name of fileNames) {
|
||||
if (name && name !== "..") {
|
||||
newSelection.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||||
},
|
||||
[updateActiveTab],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback((side: "left" | "right") => {
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
|
||||
}, [updateActiveTab]);
|
||||
|
||||
const selectAll = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane) return;
|
||||
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
selectedFiles: new Set(
|
||||
pane.files.filter((f) => f.name !== "..").map((f) => f.name),
|
||||
),
|
||||
}));
|
||||
},
|
||||
[getActivePane, updateActiveTab],
|
||||
);
|
||||
|
||||
const setFilter = useCallback((side: "left" | "right", filter: string) => {
|
||||
updateActiveTab(side, (prev) => ({ ...prev, filter }));
|
||||
}, [updateActiveTab]);
|
||||
|
||||
const getFilteredFiles = useCallback((pane: SftpPane): SftpFileEntry[] => {
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
if (!term) return pane.files;
|
||||
return pane.files.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const createDirectory = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const createFile = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.writeLocalFile) {
|
||||
const emptyBuffer = new ArrayBuffer(0);
|
||||
await bridge.writeLocalFile(fullPath, emptyBuffer);
|
||||
} else {
|
||||
throw new Error("Local file writing not supported");
|
||||
}
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.writeSftpBinary) {
|
||||
const emptyBuffer = new ArrayBuffer(0);
|
||||
await bridge.writeSftpBinary(sftpId, fullPath, emptyBuffer, pane.filenameEncoding);
|
||||
} else if (bridge?.writeSftp) {
|
||||
await bridge.writeSftp(sftpId, fullPath, "", pane.filenameEncoding);
|
||||
} else {
|
||||
throw new Error("No write method available");
|
||||
}
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const deleteFiles = useCallback(
|
||||
async (side: "left" | "right", fileNames: string[]) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
try {
|
||||
for (const name of fileNames) {
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const renameFile = useCallback(
|
||||
async (side: "left" | "right", oldName: string, newName: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const oldPath = joinPath(pane.connection.currentPath, oldName);
|
||||
const newPath = joinPath(pane.connection.currentPath, newName);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const changePermissions = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
filePath: string,
|
||||
mode: string,
|
||||
) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) {
|
||||
logger.warn("Cannot change permissions on local files");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode, pane.filenameEncoding);
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
logger.error("Failed to change permissions:", err);
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
refresh,
|
||||
navigateUp,
|
||||
openEntry,
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
selectAll,
|
||||
setFilter,
|
||||
getFilteredFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
};
|
||||
};
|
||||
19
application/state/sftp/useSftpSessionCleanup.ts
Normal file
19
application/state/sftp/useSftpSessionCleanup.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
|
||||
useEffect(() => {
|
||||
const sessionsRef = sftpSessionsRef.current;
|
||||
|
||||
return () => {
|
||||
sessionsRef.forEach(async (sftpId) => {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP sessions during cleanup
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [sftpSessionsRef]);
|
||||
};
|
||||
78
application/state/sftp/useSftpSessionErrors.ts
Normal file
78
application/state/sftp/useSftpSessionErrors.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Host } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
interface UseSftpSessionErrorsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
updateActiveTab: (
|
||||
side: "left" | "right",
|
||||
updater: (prev: SftpPane) => SftpPane,
|
||||
) => void;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
}
|
||||
|
||||
export const useSftpSessionErrors = ({
|
||||
getActivePane,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
updateActiveTab,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
}: UseSftpSessionErrorsParams) =>
|
||||
useCallback(
|
||||
(side: "left" | "right", _error: Error) => {
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
|
||||
if (!pane || !sideTabs.activeTabId) return;
|
||||
|
||||
if (pane.connection) {
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && pane.files.length > 0 && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
error: "sftp.error.connectionLostReconnecting",
|
||||
}));
|
||||
} else {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "sftp.error.sessionLost",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
}
|
||||
},
|
||||
[
|
||||
getActivePane,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
updateActiveTab,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
],
|
||||
);
|
||||
247
application/state/sftp/useSftpTabsState.ts
Normal file
247
application/state/sftp/useSftpTabsState.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
export interface SftpTabsState {
|
||||
leftTabs: SftpSideTabs;
|
||||
rightTabs: SftpSideTabs;
|
||||
leftTabsRef: React.MutableRefObject<SftpSideTabs>;
|
||||
rightTabsRef: React.MutableRefObject<SftpSideTabs>;
|
||||
setLeftTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
|
||||
setRightTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
|
||||
leftPane: SftpPane;
|
||||
rightPane: SftpPane;
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
selectTab: (side: "left" | "right", tabId: string) => void;
|
||||
reorderTabs: (
|
||||
side: "left" | "right",
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: "before" | "after",
|
||||
) => void;
|
||||
moveTabToOtherSide: (fromSide: "left" | "right", tabId: string) => void;
|
||||
getTabsInfo: (side: "left" | "right") => Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
isLocal: boolean;
|
||||
hostId: string | null;
|
||||
}>;
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
export const useSftpTabsState = (): SftpTabsState => {
|
||||
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
const [rightTabs, setRightTabs] = useState<SftpSideTabs>({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
|
||||
const leftTabsRef = useRef(leftTabs);
|
||||
const rightTabsRef = useRef(rightTabs);
|
||||
leftTabsRef.current = leftTabs;
|
||||
rightTabsRef.current = rightTabs;
|
||||
|
||||
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
if (!sideTabs.activeTabId) return null;
|
||||
return sideTabs.tabs.find((t) => t.id === sideTabs.activeTabId) || null;
|
||||
}, []);
|
||||
|
||||
const leftPane = useMemo(() => {
|
||||
const pane = leftTabs.activeTabId
|
||||
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
|
||||
}, [leftTabs]);
|
||||
|
||||
const rightPane = useMemo(() => {
|
||||
const pane = rightTabs.activeTabId
|
||||
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
|
||||
}, [rightTabs]);
|
||||
|
||||
const updateTab = useCallback(
|
||||
(side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
...prev,
|
||||
tabs: prev.tabs.map((t) => (t.id === tabId ? updater(t) : t)),
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateActiveTab = useCallback(
|
||||
(side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
if (!sideTabs.activeTabId) return;
|
||||
updateTab(side, sideTabs.activeTabId, updater);
|
||||
},
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const addTab = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const newPane = createEmptyPane();
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
tabs: [...prev.tabs, newPane],
|
||||
activeTabId: newPane.id,
|
||||
}));
|
||||
return newPane.id;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(side: "left" | "right", tabId: string) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => {
|
||||
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
|
||||
if (tabIndex === -1) return prev;
|
||||
|
||||
let newActiveTabId: string | null = null;
|
||||
if (prev.tabs.length > 1) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
|
||||
newActiveTabId = prev.tabs[nextIndex]?.id || null;
|
||||
} else {
|
||||
newActiveTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: prev.tabs.filter((t) => t.id !== tabId),
|
||||
activeTabId: newActiveTabId,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(side: "left" | "right", tabId: string) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
...prev,
|
||||
activeTabId: tabId,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reorderTabs = useCallback(
|
||||
(
|
||||
side: "left" | "right",
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: "before" | "after",
|
||||
) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => {
|
||||
const tabs = [...prev.tabs];
|
||||
const draggedIndex = tabs.findIndex((t) => t.id === draggedId);
|
||||
const targetIndex = tabs.findIndex((t) => t.id === targetId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return prev;
|
||||
|
||||
const [draggedTab] = tabs.splice(draggedIndex, 1);
|
||||
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
|
||||
const adjustedIndex = draggedIndex < targetIndex ? insertIndex - 1 : insertIndex;
|
||||
tabs.splice(adjustedIndex, 0, draggedTab);
|
||||
|
||||
return { ...prev, tabs };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const moveTabToOtherSide = useCallback(
|
||||
(fromSide: "left" | "right", tabId: string) => {
|
||||
const sourceTabs = fromSide === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const setSourceTabs = fromSide === "left" ? setLeftTabs : setRightTabs;
|
||||
const setTargetTabs = fromSide === "left" ? setRightTabs : setLeftTabs;
|
||||
|
||||
const tabToMove = sourceTabs.tabs.find((t) => t.id === tabId);
|
||||
if (!tabToMove) return;
|
||||
|
||||
logger.info("[SFTP] Moving tab to other side", {
|
||||
fromSide,
|
||||
toSide: fromSide === "left" ? "right" : "left",
|
||||
tabId,
|
||||
hostLabel: tabToMove.connection?.hostLabel,
|
||||
});
|
||||
|
||||
setSourceTabs((prev) => {
|
||||
const newTabs = prev.tabs.filter((t) => t.id !== tabId);
|
||||
let newActiveTabId: string | null = null;
|
||||
if (newTabs.length > 0) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
newActiveTabId = newTabs[0].id;
|
||||
} else {
|
||||
newActiveTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
return { tabs: newTabs, activeTabId: newActiveTabId };
|
||||
});
|
||||
|
||||
setTargetTabs((prev) => ({
|
||||
tabs: [...prev.tabs, tabToMove],
|
||||
activeTabId: tabToMove.id,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const DEFAULT_TAB_LABEL = "New Tab";
|
||||
|
||||
const getTabsInfo = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
return sideTabs.tabs.map((pane) => ({
|
||||
id: pane.id,
|
||||
label: pane.connection?.hostLabel || DEFAULT_TAB_LABEL,
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getActiveTabId = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
return sideTabs.activeTabId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
leftTabs,
|
||||
rightTabs,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
setLeftTabs,
|
||||
setRightTabs,
|
||||
leftPane,
|
||||
rightPane,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
reorderTabs,
|
||||
moveTabToOtherSide,
|
||||
getTabsInfo,
|
||||
getActiveTabId,
|
||||
};
|
||||
};
|
||||
785
application/state/sftp/useSftpTransfers.ts
Normal file
785
application/state/sftp/useSftpTransfers.ts
Normal file
@@ -0,0 +1,785 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FileConflict,
|
||||
SftpFileEntry,
|
||||
SftpFilenameEncoding,
|
||||
TransferDirection,
|
||||
TransferStatus,
|
||||
TransferTask,
|
||||
} from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { joinPath } from "./utils";
|
||||
|
||||
interface UseSftpTransfersParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseSftpTransfersResult {
|
||||
transfers: TransferTask[];
|
||||
conflicts: FileConflict[];
|
||||
activeTransfersCount: number;
|
||||
startTransfer: (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => Promise<void>;
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpTransfers = ({
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
}: UseSftpTransfersParams): UseSftpTransfersResult => {
|
||||
const [transfers, setTransfers] = useState<TransferTask[]>([]);
|
||||
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
|
||||
|
||||
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
// Track cancelled task IDs for checking during async operations
|
||||
const cancelledTasksRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalsRef = progressIntervalsRef.current;
|
||||
return () => {
|
||||
intervalsRef.forEach((interval) => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
intervalsRef.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startProgressSimulation = useCallback(
|
||||
(taskId: string, estimatedBytes: number) => {
|
||||
const existing = progressIntervalsRef.current.get(taskId);
|
||||
if (existing) clearInterval(existing);
|
||||
|
||||
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
|
||||
const variability = 0.3;
|
||||
|
||||
let transferred = 0;
|
||||
const interval = setInterval(() => {
|
||||
const speedFactor = 1 + (Math.random() - 0.5) * variability;
|
||||
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
|
||||
transferred = Math.min(transferred + chunkSize, estimatedBytes);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskId || t.status !== "transferring") return t;
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: estimatedBytes,
|
||||
speed: chunkSize * 10,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (transferred >= estimatedBytes * 0.95) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
progressIntervalsRef.current.set(taskId, interval);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopProgressSimulation = useCallback((taskId: string) => {
|
||||
const interval = progressIntervalsRef.current.get(taskId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transferFile = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (netcattyBridge.get()?.startStreamTransfer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
};
|
||||
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total || t.totalBytes,
|
||||
speed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
let content: ArrayBuffer | string;
|
||||
|
||||
if (sourceIsLocal) {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
|
||||
new ArrayBuffer(0);
|
||||
} else if (sourceSftpId) {
|
||||
if (netcattyBridge.get()?.readSftpBinary) {
|
||||
content = await netcattyBridge.get()!.readSftpBinary!(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
} else {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
|
||||
}
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
if (targetIsLocal) {
|
||||
if (content instanceof ArrayBuffer) {
|
||||
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
|
||||
} else {
|
||||
const encoder = new TextEncoder();
|
||||
await netcattyBridge.get()?.writeLocalFile?.(
|
||||
task.targetPath,
|
||||
encoder.encode(content).buffer,
|
||||
);
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
|
||||
await netcattyBridge.get()!.writeSftpBinary!(
|
||||
targetSftpId,
|
||||
task.targetPath,
|
||||
content,
|
||||
targetEncoding,
|
||||
);
|
||||
} else {
|
||||
const text =
|
||||
content instanceof ArrayBuffer
|
||||
? new TextDecoder().decode(content)
|
||||
: content;
|
||||
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
|
||||
}
|
||||
} else {
|
||||
throw new Error("No target connection");
|
||||
}
|
||||
};
|
||||
|
||||
const transferDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (targetIsLocal) {
|
||||
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
|
||||
} else if (targetSftpId) {
|
||||
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[];
|
||||
if (sourceIsLocal) {
|
||||
files = await listLocalFiles(task.sourcePath);
|
||||
} else if (sourceSftpId) {
|
||||
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
// Check if root task was cancelled during iteration
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const childTask: TransferTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
sourcePath: joinPath(task.sourcePath, file.name),
|
||||
targetPath: joinPath(task.targetPath, file.name),
|
||||
isDirectory: file.type === "directory",
|
||||
parentTaskId: task.id,
|
||||
};
|
||||
|
||||
if (file.type === "directory") {
|
||||
await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processTransfer = async (
|
||||
task: TransferTask,
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
const updateTask = (updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
|
||||
);
|
||||
};
|
||||
|
||||
// Initialize encoding early to avoid temporal dead zone issues
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection?.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
const targetEncoding: SftpFilenameEncoding = targetPane.connection?.isLocal
|
||||
? "auto"
|
||||
: targetPane.filenameEncoding || "auto";
|
||||
|
||||
let actualFileSize = task.totalBytes;
|
||||
if (!task.isDirectory && actualFileSize === 0) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
// Ignore stat errors
|
||||
}
|
||||
}
|
||||
|
||||
const estimatedSize =
|
||||
actualFileSize > 0
|
||||
? actualFileSize
|
||||
: task.isDirectory
|
||||
? 1024 * 1024
|
||||
: 256 * 1024;
|
||||
|
||||
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
|
||||
|
||||
updateTask({
|
||||
status: "transferring",
|
||||
totalBytes: estimatedSize,
|
||||
transferredBytes: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
const targetSftpId = targetPane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(targetPane.connection!.id);
|
||||
|
||||
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
|
||||
const sourceSide = targetSide === "left" ? "right" : "left";
|
||||
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
|
||||
throw new Error("Source SFTP session not found");
|
||||
}
|
||||
|
||||
if (!targetPane.connection?.isLocal && !targetSftpId) {
|
||||
handleSessionError(targetSide, new Error("Target SFTP session lost"));
|
||||
throw new Error("Target SFTP session not found");
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
if (!hasStreamingTransfer || task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
|
||||
let targetExists = false;
|
||||
let existingStat: { size: number; mtime: number } | null = null;
|
||||
let sourceStat: { size: number; mtime: number } | null = null;
|
||||
|
||||
try {
|
||||
if (sourcePane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
|
||||
if (stat) {
|
||||
targetExists = true;
|
||||
existingStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
task.targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
targetExists = true;
|
||||
existingStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (targetExists && existingStat) {
|
||||
stopProgressSimulation(task.id);
|
||||
|
||||
const newConflict: FileConflict = {
|
||||
transferId: task.id,
|
||||
fileName: task.fileName,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
existingSize: existingStat.size,
|
||||
newSize: sourceStat?.size || estimatedSize,
|
||||
existingModified: existingStat.mtime,
|
||||
newModified: sourceStat?.mtime || Date.now(),
|
||||
};
|
||||
setConflicts((prev) => [...prev, newConflict]);
|
||||
updateTask({
|
||||
status: "pending",
|
||||
totalBytes: sourceStat?.size || estimatedSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (task.isDirectory) {
|
||||
await transferDirectory(
|
||||
task,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourcePane.connection!.isLocal,
|
||||
targetPane.connection!.isLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourcePane.connection!.isLocal,
|
||||
targetPane.connection!.isLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
);
|
||||
}
|
||||
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
return {
|
||||
...t,
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
transferredBytes: t.totalBytes,
|
||||
speed: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await refresh(targetSide);
|
||||
} catch (err) {
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
// Check if this was a cancellation
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id) ||
|
||||
(err instanceof Error && err.message === "Transfer cancelled");
|
||||
|
||||
if (isCancelled) {
|
||||
// Don't update status - cancelTransfer already set it to cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startTransfer = useCallback(
|
||||
async (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
const sourcePane = getActivePane(sourceSide);
|
||||
const targetPane = getActivePane(targetSide);
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return;
|
||||
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
|
||||
const sourcePath = sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection.id);
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const direction: TransferDirection =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
? "upload"
|
||||
: !sourcePane.connection!.isLocal && targetPane.connection!.isLocal
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
let fileSize = 0;
|
||||
if (!file.isDirectory) {
|
||||
try {
|
||||
const fullPath = joinPath(sourcePath, file.name);
|
||||
if (sourcePane.connection!.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
|
||||
if (stat) fileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) fileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
sourcePath: joinPath(sourcePath, file.name),
|
||||
targetPath: joinPath(targetPath, file.name),
|
||||
sourceConnectionId: sourcePane.connection!.id,
|
||||
targetConnectionId: targetPane.connection!.id,
|
||||
direction,
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: file.isDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
setTransfers((prev) => [...prev, ...newTasks]);
|
||||
|
||||
for (const task of newTasks) {
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const cancelTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
// Add to cancelled set so async operations can check
|
||||
cancelledTasksRef.current.add(transferId);
|
||||
|
||||
stopProgressSimulation(transferId);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? {
|
||||
...t,
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
|
||||
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
|
||||
|
||||
if (netcattyBridge.get()?.cancelTransfer) {
|
||||
try {
|
||||
await netcattyBridge.get()!.cancelTransfer!(transferId);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel transfer at backend:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cancelled task ID after a delay to ensure all async ops see it
|
||||
setTimeout(() => {
|
||||
cancelledTasksRef.current.delete(transferId);
|
||||
}, 5000);
|
||||
},
|
||||
[stopProgressSimulation],
|
||||
);
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
const task = transfers.find((t) => t.id === transferId);
|
||||
if (!task) return;
|
||||
|
||||
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
|
||||
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
|
||||
const sourcePane = getActivePane(sourceSide as "left" | "right");
|
||||
const targetPane = getActivePane(targetSide as "left" | "right");
|
||||
|
||||
if (sourcePane?.connection && targetPane?.connection) {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: "pending" as TransferStatus, error: undefined }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[transfers, getActivePane],
|
||||
);
|
||||
|
||||
const clearCompletedTransfers = useCallback(() => {
|
||||
setTransfers((prev) =>
|
||||
prev.filter((t) => t.status !== "completed" && t.status !== "cancelled"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dismissTransfer = useCallback((transferId: string) => {
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
|
||||
}, []);
|
||||
|
||||
const addExternalUpload = useCallback((task: TransferTask) => {
|
||||
// Filter out any pending scanning tasks before adding the new task.
|
||||
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
|
||||
// (due to React state batching), the scanning placeholder will still be removed.
|
||||
setTransfers((prev) => [
|
||||
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
|
||||
task
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const resolveConflict = useCallback(
|
||||
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
|
||||
const conflict = conflicts.find((c) => c.transferId === conflictId);
|
||||
if (!conflict) return;
|
||||
|
||||
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
|
||||
|
||||
const task = transfers.find((t) => t.id === conflictId);
|
||||
if (!task) return;
|
||||
|
||||
if (action === "skip") {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === conflictId
|
||||
? { ...t, status: "cancelled" as TransferStatus }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedTask = { ...task };
|
||||
|
||||
if (action === "duplicate") {
|
||||
const ext = task.fileName.includes(".")
|
||||
? "." + task.fileName.split(".").pop()
|
||||
: "";
|
||||
const baseName = task.fileName.includes(".")
|
||||
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
|
||||
: task.fileName;
|
||||
const newName = `${baseName} (copy)${ext}`;
|
||||
const newTargetPath = task.targetPath.replace(task.fileName, newName);
|
||||
updatedTask = {
|
||||
...task,
|
||||
fileName: newName,
|
||||
targetPath: newTargetPath,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
updatedTask = {
|
||||
...task,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === conflictId
|
||||
? { ...updatedTask, status: "pending" as TransferStatus }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
|
||||
const sourceSide = updatedTask.sourceConnectionId.startsWith("left") ? "left" : "right";
|
||||
const targetSide = updatedTask.targetConnectionId.startsWith("left") ? "left" : "right";
|
||||
const sourcePane = getActivePane(sourceSide as "left" | "right");
|
||||
const targetPane = getActivePane(targetSide as "left" | "right");
|
||||
|
||||
if (sourcePane?.connection && targetPane?.connection) {
|
||||
setTimeout(async () => {
|
||||
await processTransfer(updatedTask, sourcePane, targetPane, targetSide);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[conflicts, transfers, getActivePane],
|
||||
);
|
||||
|
||||
const activeTransfersCount = useMemo(() => transfers.filter(
|
||||
(t) => t.status === "pending" || t.status === "transferring",
|
||||
).length, [transfers]);
|
||||
|
||||
return {
|
||||
transfers,
|
||||
conflicts,
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
};
|
||||
};
|
||||
90
application/state/sftp/utils.ts
Normal file
90
application/state/sftp/utils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { SftpFileEntry } from "../../../domain/models";
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "--";
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
};
|
||||
|
||||
export const formatDate = (timestamp: number): string => {
|
||||
if (!timestamp) return "--";
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return "--";
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
export const getFileExtension = (name: string): string => {
|
||||
if (name === "..") return "folder";
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
return ext || "file";
|
||||
};
|
||||
|
||||
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
|
||||
};
|
||||
|
||||
// Check if path is Windows-style
|
||||
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
|
||||
|
||||
const normalizeWindowsRoot = (path: string): string => {
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
|
||||
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const isWindowsRoot = (path: string): boolean => {
|
||||
if (!isWindowsPath(path)) return false;
|
||||
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
|
||||
};
|
||||
|
||||
export const joinPath = (base: string, name: string): string => {
|
||||
if (isWindowsPath(base)) {
|
||||
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string): string => {
|
||||
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
|
||||
|
||||
if (isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const drive = normalized.slice(0, 2);
|
||||
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
|
||||
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
if (parts.length <= 1) {
|
||||
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
parts.pop();
|
||||
const result = `${drive}\\${parts.join("\\")}`;
|
||||
console.log("[SFTP getParentPath] Windows result", { result });
|
||||
return result;
|
||||
}
|
||||
if (path === "/") {
|
||||
console.log("[SFTP getParentPath] Unix root, returning /");
|
||||
return "/";
|
||||
}
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
|
||||
parts.pop();
|
||||
const result = parts.length ? `/${parts.join("/")}` : "/";
|
||||
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getFileName = (path: string): string => {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || "";
|
||||
};
|
||||
207
application/state/uiFontStore.ts
Normal file
207
application/state/uiFontStore.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { UI_FONTS, withUiCjkFallback, type UIFont } from '../../infrastructure/config/uiFonts';
|
||||
|
||||
/**
|
||||
* UI Font Store - singleton pattern using useSyncExternalStore
|
||||
* Fetches system fonts and combines with bundled fonts
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
interface UIFontStoreState {
|
||||
availableFonts: UIFont[];
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type definition for Local Font Access API
|
||||
*/
|
||||
interface LocalFontData {
|
||||
family: string;
|
||||
}
|
||||
|
||||
class UIFontStore {
|
||||
private state: UIFontStoreState = {
|
||||
availableFonts: UI_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getAvailableFonts = (): UIFont[] => this.state.availableFonts;
|
||||
getIsLoading = (): boolean => this.state.isLoading;
|
||||
getIsLoaded = (): boolean => this.state.isLoaded;
|
||||
|
||||
private notify = () => {
|
||||
Promise.resolve().then(() => {
|
||||
this.listeners.forEach(listener => listener());
|
||||
});
|
||||
};
|
||||
|
||||
private setState = (partial: Partial<UIFontStoreState>) => {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.notify();
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
initialize = async (): Promise<void> => {
|
||||
if (this.state.isLoaded || this.state.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const localFonts = await this.getLocalFonts();
|
||||
|
||||
// Use a Map to deduplicate by normalized font name
|
||||
const fontMap = new Map<string, UIFont>();
|
||||
|
||||
// Add bundled fonts first (they have priority)
|
||||
UI_FONTS.forEach(font => fontMap.set(font.id, font));
|
||||
|
||||
// Add local fonts with a distinct ID namespace
|
||||
localFonts.forEach(font => {
|
||||
const localId = `local-${font.id}`;
|
||||
// Skip if a bundled font with similar name exists
|
||||
if (!fontMap.has(font.id)) {
|
||||
fontMap.set(localId, { ...font, id: localId });
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
availableFonts: Array.from(fontMap.values()),
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
|
||||
console.warn('Failed to fetch local UI fonts, using defaults:', error);
|
||||
this.setState({
|
||||
availableFonts: UI_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private async getLocalFonts(): Promise<UIFont[]> {
|
||||
if (typeof window === 'undefined' || !('queryLocalFonts' in window)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
|
||||
const fonts = await queryLocalFonts();
|
||||
|
||||
// Deduplicate by family name
|
||||
const uniqueFamilies = new Set<string>();
|
||||
const dedupedFonts = fonts.filter(f => {
|
||||
if (uniqueFamilies.has(f.family)) return false;
|
||||
uniqueFamilies.add(f.family);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Map to UIFont structure
|
||||
return dedupedFonts.map(f => ({
|
||||
id: f.family.toLowerCase().replace(/\s+/g, '-'),
|
||||
name: f.family,
|
||||
family: withUiCjkFallback(`"${f.family}", system-ui`),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('Failed to query local fonts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getFontById = (fontId: string): UIFont => {
|
||||
const fonts = this.state.availableFonts;
|
||||
const found = fonts.find(f => f.id === fontId);
|
||||
if (found) return found;
|
||||
|
||||
// For local fonts that haven't been loaded yet, construct a fallback
|
||||
// This handles the case when main window receives a local font ID before fonts are loaded
|
||||
if (fontId.startsWith('local-')) {
|
||||
const fontName = fontId
|
||||
.replace(/^local-/, '')
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
return {
|
||||
id: fontId,
|
||||
name: fontName,
|
||||
family: withUiCjkFallback(`"${fontName}", system-ui`),
|
||||
};
|
||||
}
|
||||
|
||||
return fonts[0] || UI_FONTS[0];
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const uiFontStore = new UIFontStore();
|
||||
|
||||
/**
|
||||
* Get available UI fonts - triggers initialization on first use
|
||||
*/
|
||||
export const useAvailableUIFonts = (): UIFont[] => {
|
||||
if (!uiFontStore.getIsLoaded() && !uiFontStore.getIsLoading()) {
|
||||
uiFontStore.initialize();
|
||||
}
|
||||
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getAvailableFonts
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loading state
|
||||
*/
|
||||
export const useUIFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loaded state
|
||||
*/
|
||||
export const useUIFontsLoaded = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getIsLoaded
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font by ID with fallback
|
||||
*/
|
||||
export const useUIFontById = (fontId: string): UIFont => {
|
||||
const fonts = useAvailableUIFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a font ID is valid
|
||||
*/
|
||||
export const isValidUiFontId = (fontId: string): boolean => {
|
||||
// Local fonts are always considered valid (they start with 'local-')
|
||||
if (fontId.startsWith('local-')) return true;
|
||||
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize UI fonts eagerly
|
||||
*/
|
||||
export const initializeUIFonts = (): void => {
|
||||
uiFontStore.initialize();
|
||||
};
|
||||
@@ -76,10 +76,14 @@ export const usePortForwardingAutoStart = ({
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
autoStartExecutedRef.current = true;
|
||||
|
||||
const runAutoStart = async () => {
|
||||
// First sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
|
||||
// Load rules from storage
|
||||
const rules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
@@ -95,8 +99,6 @@ export const usePortForwardingAutoStart = ({
|
||||
});
|
||||
|
||||
if (autoStartRules.length === 0) return;
|
||||
|
||||
autoStartExecutedRef.current = true;
|
||||
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
|
||||
|
||||
// Start each auto-start rule
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
@@ -76,6 +76,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
|
||||
});
|
||||
|
||||
// Track if sync has been executed for this component instance
|
||||
const syncExecutedRef = useRef(false);
|
||||
|
||||
const setPreferFormMode = useCallback((prefer: boolean) => {
|
||||
setPreferFormModeState(prefer);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
@@ -84,9 +87,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
// Load rules from storage on mount and sync with backend
|
||||
useEffect(() => {
|
||||
const loadAndSync = async () => {
|
||||
// First, sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
|
||||
if (!syncExecutedRef.current) {
|
||||
syncExecutedRef.current = true;
|
||||
await syncWithBackend();
|
||||
}
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
|
||||
@@ -547,6 +547,31 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Copy a session - creates a new session with the same host connection
|
||||
const copySession = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
@@ -662,5 +687,7 @@ export const useSessionState = () => {
|
||||
logViews,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
// Copy session
|
||||
copySession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
|
||||
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage } from '../../domain/models';
|
||||
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
@@ -16,14 +16,20 @@ STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -44,6 +50,10 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
|
||||
// Session Logs defaults
|
||||
const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
|
||||
const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
if (!raw) return null;
|
||||
@@ -70,6 +80,14 @@ const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
|
||||
return list.some((preset) => preset.id === value);
|
||||
};
|
||||
|
||||
const isValidUiFontId = (value: string): boolean => {
|
||||
// Local fonts are always considered valid
|
||||
if (value.startsWith('local-')) return true;
|
||||
// Check bundled fonts first, then check dynamically loaded fonts
|
||||
return UI_FONTS.some((font) => font.id === value) ||
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
};
|
||||
|
||||
const applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
@@ -112,6 +130,7 @@ const applyThemeTokens = (
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
|
||||
@@ -134,6 +153,10 @@ export const useSettingsState = () => {
|
||||
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
|
||||
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
|
||||
});
|
||||
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
|
||||
});
|
||||
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
|
||||
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
|
||||
@@ -174,6 +197,20 @@ export const useSettingsState = () => {
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
|
||||
});
|
||||
|
||||
// Session Logs Settings
|
||||
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_ENABLED;
|
||||
});
|
||||
const [sessionLogsDir, setSessionLogsDir] = useState<string>(() => {
|
||||
return readStoredString(STORAGE_KEY_SESSION_LOGS_DIR) || '';
|
||||
});
|
||||
const [sessionLogsFormat, setSessionLogsFormat] = useState<SessionLogFormat>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_FORMAT);
|
||||
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
try {
|
||||
@@ -233,6 +270,15 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
}, [uiLanguage, notifySettingsChanged]);
|
||||
|
||||
// Apply and persist UI font family
|
||||
// Re-run when fonts finish loading to get correct family for local fonts
|
||||
useLayoutEffect(() => {
|
||||
const font = uiFontStore.getFontById(uiFontFamilyId);
|
||||
document.documentElement.style.setProperty('--font-sans', font.family);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -257,6 +303,11 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
|
||||
syncCustomCssFromStorage();
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
|
||||
if (isValidUiFontId(value)) {
|
||||
setUiFontFamilyId(value);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
@@ -343,6 +394,11 @@ export const useSettingsState = () => {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== hotkeyScheme) {
|
||||
@@ -415,7 +471,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -484,6 +540,22 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
|
||||
}, [sessionLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
}, [sessionLogsDir, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -564,6 +636,8 @@ export const useSettingsState = () => {
|
||||
setAccentMode,
|
||||
customAccent,
|
||||
setCustomAccent,
|
||||
uiFontFamilyId,
|
||||
setUiFontFamilyId,
|
||||
syncConfig,
|
||||
updateSyncConfig,
|
||||
uiLanguage,
|
||||
@@ -597,5 +671,12 @@ export const useSettingsState = () => {
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import type { RemoteFile } from "../../types";
|
||||
import type { RemoteFile, SftpFilenameEncoding } from "../../types";
|
||||
|
||||
export const useSftpBackend = () => {
|
||||
const openSftp = useCallback(async (options: NetcattySSHOptions) => {
|
||||
@@ -15,34 +15,34 @@ export const useSftpBackend = () => {
|
||||
return bridge.closeSftp(sftpId);
|
||||
}, []);
|
||||
|
||||
const listSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const listSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSftp) throw new Error("SFTP bridge unavailable");
|
||||
return bridge.listSftp(sftpId, path);
|
||||
return bridge.listSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const readSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const readSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readSftp) throw new Error("SFTP bridge unavailable");
|
||||
return bridge.readSftp(sftpId, path);
|
||||
return bridge.readSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const readSftpBinary = useCallback(async (sftpId: string, path: string) => {
|
||||
const readSftpBinary = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readSftpBinary) throw new Error("readSftpBinary unavailable");
|
||||
return bridge.readSftpBinary(sftpId, path);
|
||||
return bridge.readSftpBinary(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const writeSftp = useCallback(async (sftpId: string, path: string, content: string) => {
|
||||
const writeSftp = useCallback(async (sftpId: string, path: string, content: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeSftp) throw new Error("SFTP bridge unavailable");
|
||||
return bridge.writeSftp(sftpId, path, content);
|
||||
return bridge.writeSftp(sftpId, path, content, encoding);
|
||||
}, []);
|
||||
|
||||
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer) => {
|
||||
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeSftpBinary) throw new Error("writeSftpBinary unavailable");
|
||||
return bridge.writeSftpBinary(sftpId, path, content);
|
||||
return bridge.writeSftpBinary(sftpId, path, content, encoding);
|
||||
}, []);
|
||||
|
||||
const writeSftpBinaryWithProgress = useCallback(
|
||||
@@ -51,6 +51,7 @@ export const useSftpBackend = () => {
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void,
|
||||
@@ -62,6 +63,7 @@ export const useSftpBackend = () => {
|
||||
path,
|
||||
content,
|
||||
transferId,
|
||||
encoding,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
@@ -70,34 +72,34 @@ export const useSftpBackend = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const mkdirSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const mkdirSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.mkdirSftp) throw new Error("mkdirSftp unavailable");
|
||||
return bridge.mkdirSftp(sftpId, path);
|
||||
return bridge.mkdirSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const deleteSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const deleteSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.deleteSftp) throw new Error("deleteSftp unavailable");
|
||||
return bridge.deleteSftp(sftpId, path);
|
||||
return bridge.deleteSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string) => {
|
||||
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.renameSftp) throw new Error("renameSftp unavailable");
|
||||
return bridge.renameSftp(sftpId, oldPath, newPath);
|
||||
return bridge.renameSftp(sftpId, oldPath, newPath, encoding);
|
||||
}, []);
|
||||
|
||||
const statSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const statSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.statSftp) throw new Error("statSftp unavailable");
|
||||
return bridge.statSftp(sftpId, path);
|
||||
return bridge.statSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string) => {
|
||||
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.chmodSftp) throw new Error("chmodSftp unavailable");
|
||||
return bridge.chmodSftp(sftpId, path, mode);
|
||||
return bridge.chmodSftp(sftpId, path, mode, encoding);
|
||||
}, []);
|
||||
|
||||
const listLocalDir = useCallback(async (path: string): Promise<RemoteFile[]> => {
|
||||
@@ -168,6 +170,12 @@ export const useSftpBackend = () => {
|
||||
return bridge.cancelTransfer(transferId);
|
||||
}, []);
|
||||
|
||||
const cancelSftpUpload = useCallback(async (transferId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.cancelSftpUpload) return undefined;
|
||||
return bridge.cancelSftpUpload(transferId);
|
||||
}, []);
|
||||
|
||||
const onTransferProgress = useCallback((transferId: string, cb: Parameters<NonNullable<NetcattyBridge["onTransferProgress"]>>[1]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTransferProgress) return undefined;
|
||||
@@ -185,7 +193,7 @@ export const useSftpBackend = () => {
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
options?: { enableWatch?: boolean; encoding?: SftpFilenameEncoding }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
@@ -194,7 +202,7 @@ export const useSftpBackend = () => {
|
||||
|
||||
// Download the file to temp
|
||||
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
|
||||
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
|
||||
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
|
||||
|
||||
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
|
||||
@@ -217,7 +225,7 @@ export const useSftpBackend = () => {
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
|
||||
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
|
||||
} catch (err) {
|
||||
@@ -257,9 +265,9 @@ export const useSftpBackend = () => {
|
||||
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
cancelSftpUpload,
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
downloadSftpToTempAndOpen,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,12 @@ export const useTerminalBackend = () => {
|
||||
return bridge.getSessionPwd(sessionId);
|
||||
}, []);
|
||||
|
||||
const getServerStats = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
|
||||
return bridge.getServerStats(sessionId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
@@ -138,6 +144,7 @@ export const useTerminalBackend = () => {
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Usb,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo } from "react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
@@ -149,7 +149,11 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
||||
onOpenLogView,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const RENDER_LIMIT = 100;
|
||||
const INITIAL_RENDER_LIMIT = 30;
|
||||
const LOAD_MORE_COUNT = 30;
|
||||
|
||||
// Track how many items to show
|
||||
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
|
||||
|
||||
// Sort logs by newest first
|
||||
const filteredLogs = useMemo(() => {
|
||||
@@ -157,10 +161,14 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
||||
}, [logs]);
|
||||
|
||||
const displayedLogs = useMemo(() => {
|
||||
return filteredLogs.slice(0, RENDER_LIMIT);
|
||||
}, [filteredLogs]);
|
||||
return filteredLogs.slice(0, renderLimit);
|
||||
}, [filteredLogs, renderLimit]);
|
||||
|
||||
const hasMore = filteredLogs.length > RENDER_LIMIT;
|
||||
const hasMore = filteredLogs.length > renderLimit;
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
|
||||
}, []);
|
||||
|
||||
const handleToggleSaved = useCallback(
|
||||
(id: string) => onToggleSaved(id),
|
||||
@@ -222,9 +230,12 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
||||
<>
|
||||
{renderedItems}
|
||||
{hasMore && (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t("logs.showing", { limit: RENDER_LIMIT, total: filteredLogs.length })}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderLock,
|
||||
FolderPlus,
|
||||
Forward,
|
||||
@@ -43,6 +45,7 @@ import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
|
||||
// Import host-details sub-panels
|
||||
import {
|
||||
@@ -122,6 +125,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Identity suggestion dropdown state (popover anchored to username input)
|
||||
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
|
||||
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// New group creation state
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
@@ -163,6 +169,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
setForm(updatedData);
|
||||
setGroupInputValue(initialData.group || "");
|
||||
// Reset password visibility when host changes for privacy
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
@@ -243,12 +251,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.hostname || !form.label) return;
|
||||
if (!form.hostname) return;
|
||||
// If label is empty, use hostname as label
|
||||
const finalLabel = form.label?.trim() || form.hostname;
|
||||
const cleaned: Host = {
|
||||
...form,
|
||||
label: finalLabel,
|
||||
group: groupInputValue.trim() || form.group,
|
||||
tags: form.tags || [],
|
||||
port: form.port || 22,
|
||||
// Clear password if savePassword is explicitly set to false
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
};
|
||||
onSave(cleaned);
|
||||
};
|
||||
@@ -498,7 +511,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!form.hostname || !form.label}
|
||||
disabled={!form.hostname}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
@@ -797,13 +810,36 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
|
||||
{!selectedIdentity && !form.identityId && (
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type="password"
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Password toggle - shown when password is entered */}
|
||||
{!selectedIdentity && !form.identityId && form.password && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.password.save")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={form.savePassword ?? true}
|
||||
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
@@ -987,6 +1023,27 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</div>
|
||||
<Select
|
||||
value={form.sftpEncoding || "auto"}
|
||||
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
@@ -1423,7 +1480,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Button
|
||||
className="w-full h-10"
|
||||
onClick={handleSubmit}
|
||||
disabled={!form.hostname || !form.label}
|
||||
disabled={!form.hostname}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -280,6 +280,29 @@ const HostForm: React.FC<HostFormProps> = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sftp-encoding">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.sftpEncoding || "auto"}
|
||||
onValueChange={(val) =>
|
||||
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sftp-encoding">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Label>{t("hostForm.auth.method")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { FileText, Palette, X } from "lucide-react";
|
||||
import { FileText, Download, Palette, X } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -34,6 +34,7 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
@@ -67,6 +68,30 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
onUpdateLog(log.id, { fontSize });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
// Handle export
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!log.terminalData || isExporting) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.exportSessionLog) {
|
||||
await bridge.exportSessionLog({
|
||||
terminalData: log.terminalData,
|
||||
hostLabel: log.hostLabel,
|
||||
hostname: log.hostname,
|
||||
startTime: log.startTime,
|
||||
format: 'txt',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export session log:', err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
|
||||
|
||||
// Initialize terminal
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isVisible) return;
|
||||
@@ -216,6 +241,21 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export button */}
|
||||
{log.terminalData && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
title={t("logView.export")}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="text-xs">{t("logView.export")}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Theme & font customization button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
169
components/PassphraseModal.tsx
Normal file
169
components/PassphraseModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Passphrase Modal
|
||||
* Modal for requesting passphrase for encrypted SSH keys
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface PassphraseRequest {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onSkip?: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting || !passphrase) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, passphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!request || !onSkip) return;
|
||||
onSkip(request.requestId);
|
||||
}, [request, onSkip]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting && passphrase) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting, passphrase]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{request.hostname
|
||||
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
||||
: t("passphrase.desc", { keyName: keyDisplayName })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passphrase-input">
|
||||
{t("passphrase.label")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passphrase-input"
|
||||
type={showPassphrase ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{onSkip && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("passphrase.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("passphrase.unlocking")}
|
||||
</>
|
||||
) : (
|
||||
t("passphrase.unlock")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassphraseModal;
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useEffect, useRef, useState } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
@@ -21,6 +21,42 @@ import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
// Compute once at module level
|
||||
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
// Memoized host item component to prevent unnecessary re-renders
|
||||
const HostItem = memo(({
|
||||
host,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
host: Host;
|
||||
isSelected: boolean;
|
||||
onSelect: (host: Host) => void;
|
||||
onMouseEnter: () => void;
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => onSelect(host)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{host.label}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{host.group ? `Personal / ${host.group}` : "Personal"}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
HostItem.displayName = "HostItem";
|
||||
|
||||
interface QuickSwitcherProps {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
@@ -52,12 +88,11 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Get hotkey display strings
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const getHotkeyLabel = (actionId: string) => {
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
const binding = keyBindings?.find(k => k.id === actionId);
|
||||
if (!binding) return '';
|
||||
return isMac ? binding.mac : binding.pc;
|
||||
};
|
||||
return IS_MAC ? binding.mac : binding.pc;
|
||||
}, [keyBindings]);
|
||||
const quickSwitchKey = getHotkeyLabel('quick-switch');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -93,15 +128,16 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
// Memoize orphan sessions
|
||||
const orphanSessions = useMemo(
|
||||
() => sessions.filter((s) => !s.workspaceId),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const showCategorized = isFocused || query.trim().length > 0;
|
||||
|
||||
// Get orphan sessions (sessions without workspace)
|
||||
const orphanSessions = sessions.filter((s) => !s.workspaceId);
|
||||
|
||||
// Build categorized items for navigation
|
||||
const buildFlatItems = () => {
|
||||
// Memoize flat items list and index map
|
||||
const { flatItems, itemIndexMap } = useMemo(() => {
|
||||
const items: QuickSwitcherItem[] = [];
|
||||
|
||||
if (showCategorized) {
|
||||
@@ -127,10 +163,21 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
// Build index map for O(1) lookup
|
||||
const indexMap = new Map<string, number>();
|
||||
items.forEach((item, idx) => {
|
||||
indexMap.set(`${item.type}:${item.id}`, idx);
|
||||
});
|
||||
|
||||
const flatItems = buildFlatItems();
|
||||
return { flatItems: items, itemIndexMap: indexMap };
|
||||
}, [showCategorized, results, orphanSessions, workspaces]);
|
||||
|
||||
// O(1) index lookup
|
||||
const getItemIndex = useCallback((type: string, id: string) => {
|
||||
return itemIndexMap.get(`${type}:${id}`) ?? -1;
|
||||
}, [itemIndexMap]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
@@ -165,40 +212,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get item index in flat list
|
||||
const getItemIndex = (type: string, id: string) => {
|
||||
return flatItems.findIndex((item) => item.type === type && item.id === id);
|
||||
};
|
||||
|
||||
const renderHostItem = (host: Host) => {
|
||||
const idx = getItemIndex("host", host.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelect(host);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{host.label}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{host.group ? `Personal / ${host.group}` : "Personal"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
|
||||
@@ -260,7 +273,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
{results.length > 0 ? (
|
||||
results.map(renderHostItem)
|
||||
results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No recent connections
|
||||
@@ -289,7 +310,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
Hosts
|
||||
</span>
|
||||
</div>
|
||||
{results.map(renderHostItem)}
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
}, [closeSettingsWindow]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background text-foreground">
|
||||
<div className="h-screen flex flex-col bg-background text-foreground font-sans">
|
||||
<div className="shrink-0 border-b border-border app-drag">
|
||||
<div className="flex items-center justify-between px-4 pt-3">
|
||||
{isMac && <div className="h-6" />}
|
||||
@@ -158,6 +158,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setAccentMode={settings.setAccentMode}
|
||||
customAccent={settings.customAccent}
|
||||
setCustomAccent={settings.setCustomAccent}
|
||||
uiFontFamilyId={settings.uiFontFamilyId}
|
||||
setUiFontFamilyId={settings.setUiFontFamilyId}
|
||||
uiLanguage={settings.uiLanguage}
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
@@ -201,7 +203,16 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && <SettingsSystemTab />}
|
||||
{mountedTabs.has("system") && (
|
||||
<SettingsSystemTab
|
||||
sessionLogsEnabled={settings.sessionLogsEnabled}
|
||||
setSessionLogsEnabled={settings.setSessionLogsEnabled}
|
||||
sessionLogsDir={settings.sessionLogsDir}
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -565,12 +565,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
{!snippets.length && displayedPackages.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full text-center space-y-3 py-12 rounded-2xl bg-secondary/60 border border-border/60 shadow-lg">
|
||||
<div className="mx-auto h-12 w-12 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
|
||||
<FileCode size={22} />
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<FileCode size={32} className="opacity-60" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">{t('snippets.empty.title')}</div>
|
||||
<div className="text-xs text-muted-foreground px-8">{t('snippets.empty.desc')}</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('snippets.empty.title')}</h3>
|
||||
<p className="text-sm text-center max-w-sm">{t('snippets.empty.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Maximize2, Radio } from "lucide-react";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -26,6 +26,7 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
import SFTPModal from "./SFTPModal";
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { toast } from "./ui/toast";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
@@ -34,13 +35,13 @@ import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
||||
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { createHighlightProcessor } from "./terminal/keywordHighlight";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
|
||||
interface TerminalProps {
|
||||
host: Host;
|
||||
@@ -88,6 +89,19 @@ interface TerminalProps {
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to format network speed (bytes/sec) to human-readable format
|
||||
function formatNetSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec < 1024) {
|
||||
return `${bytesPerSec}B/s`;
|
||||
} else if (bytesPerSec < 1024 * 1024) {
|
||||
return `${(bytesPerSec / 1024).toFixed(1)}K/s`;
|
||||
} else if (bytesPerSec < 1024 * 1024 * 1024) {
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)}M/s`;
|
||||
} else {
|
||||
return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)}G/s`;
|
||||
}
|
||||
}
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -149,12 +163,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
|
||||
const highlightProcessorRef = useRef<(text: string) => string>((t) => t);
|
||||
useEffect(() => {
|
||||
highlightProcessorRef.current = createHighlightProcessor(
|
||||
terminalSettings?.keywordHighlightRules ?? [],
|
||||
terminalSettings?.keywordHighlightEnabled ?? false,
|
||||
);
|
||||
if (xtermRuntimeRef.current) {
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(
|
||||
terminalSettings?.keywordHighlightRules ?? [],
|
||||
terminalSettings?.keywordHighlightEnabled ?? false
|
||||
);
|
||||
}
|
||||
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
|
||||
|
||||
const hotkeySchemeRef = useRef(hotkeyScheme);
|
||||
@@ -208,6 +223,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
// Server stats (CPU, Memory, Disk) for Linux servers
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isLinux: host.os === 'linux',
|
||||
isConnected: status === 'connected',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
lastToastedErrorRef.current = null;
|
||||
@@ -298,7 +322,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
disposeExitRef,
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
highlightProcessorRef,
|
||||
pendingAuthRef,
|
||||
updateStatus,
|
||||
setStatus,
|
||||
@@ -525,7 +548,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
termRef.current.options.fontWeightBold = terminalSettings.fontWeightBold as
|
||||
const resolvedFontWeightBold = (() => {
|
||||
const fontFamily = termRef.current?.options.fontFamily || "";
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return terminalSettings.fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
return document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
})();
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
@@ -601,6 +635,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
logger.warn("Fit after fonts ready failed", err);
|
||||
}
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
}
|
||||
}
|
||||
|
||||
const id = sessionRef.current;
|
||||
if (id && term) {
|
||||
try {
|
||||
@@ -618,7 +673,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host.id, sessionId, resizeSession]);
|
||||
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -864,6 +919,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<TerminalContextMenu
|
||||
hasSelection={hasSelection}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
rightClickBehavior={terminalSettings?.rightClickBehavior}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
@@ -898,6 +954,266 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Server Stats Display - Linux only */}
|
||||
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.cpu")}
|
||||
>
|
||||
<Cpu size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
|
||||
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
|
||||
{serverStats.cpuPerCore.length > 0 ? (
|
||||
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
|
||||
{serverStats.cpuPerCore.map((usage, index) => (
|
||||
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
|
||||
<div className="text-[10px] text-muted-foreground">Core {index}</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${usage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-[11px] font-medium",
|
||||
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{usage}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Memory with HoverCard for htop-style bar and top processes */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.memory")}
|
||||
>
|
||||
<MemoryStick size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
{serverStats.memUsed !== null && serverStats.memTotal !== null
|
||||
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
|
||||
: '--'}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-3 min-w-[280px]">
|
||||
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
|
||||
{/* htop-style memory bar */}
|
||||
{serverStats.memTotal !== null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{/* Used (green) */}
|
||||
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Buffers (blue) */}
|
||||
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
|
||||
<div
|
||||
className="h-full bg-blue-500"
|
||||
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Cached (amber/orange) */}
|
||||
{serverStats.memCached !== null && serverStats.memCached > 0 && (
|
||||
<div
|
||||
className="h-full bg-amber-500"
|
||||
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
|
||||
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-blue-500" />
|
||||
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-amber-500" />
|
||||
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
|
||||
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Top 10 processes */}
|
||||
{serverStats.topProcesses.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
|
||||
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
|
||||
{serverStats.topProcesses.map((proc, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-[10px]">
|
||||
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
|
||||
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full"
|
||||
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
|
||||
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Disk - with HoverCard for disk details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.disk")}
|
||||
>
|
||||
<HardDrive size={10} className="flex-shrink-0" />
|
||||
<span className={cn(
|
||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
|
||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
|
||||
)}>
|
||||
{serverStats.diskUsed !== null && serverStats.diskTotal !== null && serverStats.diskPercent !== null
|
||||
? `${serverStats.diskUsed}/${serverStats.diskTotal}G (${serverStats.diskPercent}%)`
|
||||
: serverStats.diskPercent !== null
|
||||
? `${serverStats.diskPercent}%`
|
||||
: '--'}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
|
||||
{serverStats.disks.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{serverStats.disks.map((disk, index) => (
|
||||
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{disk.used}/{disk.total}G ({disk.percent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${disk.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Network - with HoverCard for per-interface details */}
|
||||
{serverStats.netInterfaces.length > 0 && (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.network")}
|
||||
>
|
||||
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
|
||||
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
|
||||
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{serverStats.netInterfaces.map((iface, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-0.5 text-emerald-400">
|
||||
<ArrowDownToLine size={9} />
|
||||
{formatNetSpeed(iface.rxSpeed)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-sky-400">
|
||||
<ArrowUpFromLine size={9} />
|
||||
{formatNetSpeed(iface.txSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader } from '@monaco-editor/react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -82,6 +82,50 @@ const languageIdToMonaco = (langId: string): string => {
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Get background color from CSS variable
|
||||
const getBackgroundColor = (): string => {
|
||||
const bgValue = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background')
|
||||
.trim();
|
||||
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -90,12 +134,13 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const monaco = useMonaco();
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
@@ -104,13 +149,49 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class
|
||||
// Track background color for custom theme
|
||||
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes based on UI background color
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
// Define dark theme with custom background
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
});
|
||||
|
||||
// Define light theme with custom background
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
});
|
||||
|
||||
// Apply the current theme
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, bgColor, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class and style
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const observer = new MutationObserver(() => {
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
setBgColor(getBackgroundColor());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
@@ -185,7 +266,6 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const monacoTheme = isDarkTheme ? 'vs-dark' : 'light';
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
@@ -265,7 +345,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={monacoTheme}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -25,6 +25,7 @@ interface TopTabsProps {
|
||||
isMacClient: boolean;
|
||||
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
@@ -121,6 +122,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
isMacClient,
|
||||
onCloseSession,
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
@@ -410,6 +412,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopySession(session.id)}>
|
||||
{t('tabs.copyTab')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
Activity,
|
||||
BookMarked,
|
||||
ChevronDown,
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Download,
|
||||
Edit2,
|
||||
FileCode,
|
||||
FolderPlus,
|
||||
@@ -23,7 +26,7 @@ import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText } from "../domain/vaultImport";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -41,7 +44,6 @@ import {
|
||||
TerminalSession,
|
||||
} from "../types";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import ConnectionLogsManager from "./ConnectionLogsManager";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import KeychainManager from "./KeychainManager";
|
||||
@@ -76,6 +78,7 @@ import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
|
||||
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
|
||||
@@ -301,6 +304,96 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDuplicateHost = useCallback((host: Host) => {
|
||||
// Create a copy of the host with a new ID and modified label
|
||||
const duplicatedHost: Host = {
|
||||
...host,
|
||||
id: crypto.randomUUID(),
|
||||
label: `${host.label} (${t('action.copy')})`,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
// Open the edit panel with the duplicated host for modification
|
||||
setEditingHost(duplicatedHost);
|
||||
setIsHostPanelOpen(true);
|
||||
}, [t]);
|
||||
|
||||
// Export hosts to CSV
|
||||
const handleExportHosts = useCallback(() => {
|
||||
if (hosts.length === 0) {
|
||||
toast.warning(t('vault.hosts.export.toast.noHosts'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { csv, exportedCount, skippedCount } = exportHostsToCsvWithStats(hosts);
|
||||
|
||||
if (exportedCount === 0) {
|
||||
toast.warning(t('vault.hosts.export.toast.noHosts'));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `hosts_export_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (skippedCount > 0) {
|
||||
toast.warning(t('vault.hosts.export.toast.successWithSkipped', { count: exportedCount, skipped: skippedCount }));
|
||||
} else {
|
||||
toast.success(t('vault.hosts.export.toast.success', { count: exportedCount }));
|
||||
}
|
||||
}, [hosts, t]);
|
||||
|
||||
// Copy host credentials to clipboard
|
||||
const handleCopyCredentials = useCallback((host: Host) => {
|
||||
// Only use telnet-specific port and credentials when protocol is explicitly telnet
|
||||
// Don't treat telnetEnabled as primary - that's just an optional protocol
|
||||
const isTelnet = host.protocol === "telnet";
|
||||
|
||||
const defaultPort = isTelnet ? 23 : 22;
|
||||
const effectivePort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
|
||||
// Bracket IPv6 addresses when appending non-default port
|
||||
let address: string;
|
||||
if (effectivePort !== defaultPort) {
|
||||
const isIPv6 = host.hostname.includes(":") && !host.hostname.startsWith("[");
|
||||
const hostname = isIPv6 ? `[${host.hostname}]` : host.hostname;
|
||||
address = `${hostname}:${effectivePort}`;
|
||||
} else {
|
||||
address = host.hostname;
|
||||
}
|
||||
|
||||
// Resolve credentials from identity if configured, otherwise use host credentials
|
||||
// For telnet hosts, use telnet-specific credentials
|
||||
const identity = host.identityId
|
||||
? identities.find((i) => i.id === host.identityId)
|
||||
: undefined;
|
||||
|
||||
const username = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim())
|
||||
: (identity?.username?.trim() || host.username?.trim());
|
||||
|
||||
const password = isTelnet
|
||||
? (host.telnetPassword || host.password)
|
||||
: (identity?.password || host.password);
|
||||
|
||||
if (!password) {
|
||||
toast.warning(t('vault.hosts.copyCredentials.toast.noPassword'));
|
||||
return;
|
||||
}
|
||||
|
||||
const text = `host: ${address}\nusername: ${username ?? ''}\npassword: ${password}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast.success(t('vault.hosts.copyCredentials.toast.success'));
|
||||
});
|
||||
}, [identities, t]);
|
||||
|
||||
const readTextFile = useCallback(async (file: File): Promise<string> => {
|
||||
const buf = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
@@ -470,7 +563,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const displayedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
if (selectedGroupPath) {
|
||||
filtered = filtered.filter((h) => (h.group || "") === selectedGroupPath);
|
||||
// Match hosts whose group equals the selected path
|
||||
// For "General" group, also match hosts with empty/undefined group
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = h.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
}
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
@@ -545,9 +646,20 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
const displayedGroups = useMemo(() => {
|
||||
if (!selectedGroupPath) {
|
||||
return (Object.values(buildGroupTree) as GroupNode[]).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
// Hide "General" group at root level only if it's auto-generated
|
||||
// (not user-created and has no subgroups)
|
||||
const isGeneralUserCreated = customGroups.some(
|
||||
(g) => g === "General" || g.startsWith("General/")
|
||||
);
|
||||
return (Object.values(buildGroupTree) as GroupNode[])
|
||||
.filter((node) => {
|
||||
if (node.name !== "General") return true;
|
||||
// Keep General if user explicitly created it or it has subgroups
|
||||
if (isGeneralUserCreated) return true;
|
||||
if (Object.keys(node.children).length > 0) return true;
|
||||
return false;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
const node = findGroupNode(selectedGroupPath);
|
||||
if (!node || !node.children) return [];
|
||||
@@ -555,7 +667,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- findGroupNode is derived from buildGroupTree
|
||||
}, [buildGroupTree, selectedGroupPath]);
|
||||
}, [buildGroupTree, selectedGroupPath, customGroups]);
|
||||
|
||||
// Known Hosts callbacks - use refs to keep stable references
|
||||
// Store latest values in refs so callbacks don't need to depend on them
|
||||
@@ -747,7 +859,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("hosts");
|
||||
@@ -761,7 +873,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "keys" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("keys");
|
||||
@@ -774,7 +886,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "port" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("port")}
|
||||
>
|
||||
@@ -785,7 +897,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "snippets" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("snippets");
|
||||
@@ -798,7 +910,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "knownhosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("knownhosts")}
|
||||
>
|
||||
@@ -809,7 +921,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "logs" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("logs")}
|
||||
>
|
||||
@@ -952,6 +1064,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -1219,18 +1338,28 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuItem
|
||||
onClick={() => handleHostConnect(host)}
|
||||
>
|
||||
<Plug className="mr-2 h-4 w-4" /> Connect
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> Edit
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleDuplicateHost(host)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleCopyCredentials(host)}
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -1350,14 +1479,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
{/* Connection Logs */}
|
||||
{currentSection === "logs" && (
|
||||
<ConnectionLogsManager
|
||||
logs={connectionLogs}
|
||||
hosts={hosts}
|
||||
onToggleSaved={onToggleConnectionLogSaved}
|
||||
onDelete={onDeleteConnectionLog}
|
||||
onClearUnsaved={onClearUnsavedConnectionLogs}
|
||||
onOpenLogView={onOpenLogView}
|
||||
/>
|
||||
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<LazyConnectionLogsManager
|
||||
logs={connectionLogs}
|
||||
hosts={hosts}
|
||||
onToggleSaved={onToggleConnectionLogSaved}
|
||||
onDelete={onDeleteConnectionLog}
|
||||
onClearUnsaved={onClearUnsavedConnectionLogs}
|
||||
onOpenLogView={onOpenLogView}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1377,8 +1508,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : selectedGroupPath}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
onUpdateHosts(
|
||||
editingHost
|
||||
hostExists
|
||||
? hosts.map((h) => (h.id === host.id ? host : h))
|
||||
: [...hosts, host],
|
||||
);
|
||||
|
||||
77
components/settings/FontSelect.tsx
Normal file
77
components/settings/FontSelect.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { UIFont } from '../../infrastructure/config/uiFonts';
|
||||
|
||||
interface FontSelectProps {
|
||||
value: string;
|
||||
fonts: UIFont[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const FontSelect: React.FC<FontSelectProps> = ({
|
||||
value,
|
||||
fonts,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
}) => {
|
||||
const selectedFont = fonts.find(f => f.id === value);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>
|
||||
<span style={{ fontFamily: selectedFont?.family }}>
|
||||
{selectedFont?.name || value}
|
||||
</span>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
{fonts.map((font) => (
|
||||
<SelectPrimitive.Item
|
||||
key={font.id}
|
||||
value={font.id}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span style={{ fontFamily: font.family }}>{font.name}</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontSelect;
|
||||
77
components/settings/TerminalFontSelect.tsx
Normal file
77
components/settings/TerminalFontSelect.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { TerminalFont } from '../../infrastructure/config/fonts';
|
||||
|
||||
interface TerminalFontSelectProps {
|
||||
value: string;
|
||||
fonts: TerminalFont[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
|
||||
value,
|
||||
fonts,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
}) => {
|
||||
const selectedFont = fonts.find(f => f.id === value);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>
|
||||
<span style={{ fontFamily: selectedFont?.family }}>
|
||||
{selectedFont?.name || value}
|
||||
</span>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
{fonts.map((font) => (
|
||||
<SelectPrimitive.Item
|
||||
key={font.id}
|
||||
value={font.id}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span style={{ fontFamily: font.family }}>{font.name}</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalFontSelect;
|
||||
@@ -2,9 +2,11 @@ import React, { useCallback } from "react";
|
||||
import { Check, Moon, Palette, Sun } from "lucide-react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
|
||||
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
|
||||
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light";
|
||||
@@ -17,12 +19,15 @@ export default function SettingsAppearanceTab(props: {
|
||||
setAccentMode: (mode: "theme" | "custom") => void;
|
||||
customAccent: string;
|
||||
setCustomAccent: (color: string) => void;
|
||||
uiFontFamilyId: string;
|
||||
setUiFontFamilyId: (fontId: string) => void;
|
||||
uiLanguage: string;
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -34,6 +39,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setAccentMode,
|
||||
customAccent,
|
||||
setCustomAccent,
|
||||
uiFontFamilyId,
|
||||
setUiFontFamilyId,
|
||||
uiLanguage,
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
@@ -130,6 +137,17 @@ export default function SettingsAppearanceTab(props: {
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.appearance.uiFont")}
|
||||
description={t("settings.appearance.uiFont.desc")}
|
||||
>
|
||||
<FontSelect
|
||||
value={uiFontFamilyId}
|
||||
fonts={availableUIFonts}
|
||||
onChange={(v) => setUiFontFamilyId(v)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.uiTheme")} />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Settings System Tab - System information and temp file management
|
||||
* Settings System Tab - System information, temp file management, and session logs
|
||||
*/
|
||||
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FileText, FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { SessionLogFormat } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
@@ -22,9 +24,25 @@ function formatBytes(bytes: number): string {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC = () => {
|
||||
interface SettingsSystemTabProps {
|
||||
sessionLogsEnabled: boolean;
|
||||
setSessionLogsEnabled: (enabled: boolean) => void;
|
||||
sessionLogsDir: string;
|
||||
setSessionLogsDir: (dir: string) => void;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
setSessionLogsFormat: (format: SessionLogFormat) => void;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
@@ -33,7 +51,7 @@ const SettingsSystemTab: React.FC = () => {
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const info = await bridge.getTempDirInfo();
|
||||
@@ -52,7 +70,7 @@ const SettingsSystemTab: React.FC = () => {
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
|
||||
|
||||
setIsClearing(true);
|
||||
setClearResult(null);
|
||||
try {
|
||||
@@ -73,6 +91,37 @@ const SettingsSystemTab: React.FC = () => {
|
||||
await bridge.openTempDir();
|
||||
}, [tempDirInfo]);
|
||||
|
||||
const handleSelectSessionLogsDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectSessionLogsDir) return;
|
||||
|
||||
try {
|
||||
const result = await bridge.selectSessionLogsDir();
|
||||
if (result.success && result.directory) {
|
||||
setSessionLogsDir(result.directory);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to select directory:", err);
|
||||
}
|
||||
}, [setSessionLogsDir]);
|
||||
|
||||
const handleOpenSessionLogsDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!sessionLogsDir || !bridge?.openSessionLogsDir) return;
|
||||
|
||||
try {
|
||||
await bridge.openSessionLogsDir(sessionLogsDir);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to open directory:", err);
|
||||
}
|
||||
}, [sessionLogsDir]);
|
||||
|
||||
const formatOptions = [
|
||||
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
|
||||
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
|
||||
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
|
||||
];
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="system"
|
||||
@@ -171,6 +220,81 @@ const SettingsSystemTab: React.FC = () => {
|
||||
{t("settings.system.tempDirectoryHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session Logs Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.sessionLogs.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Enable Toggle */}
|
||||
<SettingRow
|
||||
label={t("settings.sessionLogs.enableAutoSave")}
|
||||
description={t("settings.sessionLogs.enableAutoSaveDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={sessionLogsEnabled}
|
||||
onChange={setSessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Directory Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{t("settings.sessionLogs.directory")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
|
||||
{sessionLogsDir || t("settings.sessionLogs.noDirectory")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectSessionLogsDir}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("settings.sessionLogs.browse")}
|
||||
</Button>
|
||||
{sessionLogsDir && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenSessionLogsDir}
|
||||
className="shrink-0"
|
||||
title={t("settings.sessionLogs.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.sessionLogs.directoryHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
<SettingRow
|
||||
label={t("settings.sessionLogs.format")}
|
||||
description={t("settings.sessionLogs.formatDesc")}
|
||||
>
|
||||
<Select
|
||||
value={sessionLogsFormat}
|
||||
options={formatOptions}
|
||||
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
|
||||
className="w-32"
|
||||
disabled={!sessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.sessionLogs.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
|
||||
// Theme preview button component
|
||||
const ThemePreviewButton: React.FC<{
|
||||
@@ -207,11 +208,11 @@ export default function SettingsTerminalTab(props: {
|
||||
label={t("settings.terminal.font.family")}
|
||||
description={t("settings.terminal.font.family.desc")}
|
||||
>
|
||||
<Select
|
||||
<TerminalFontSelect
|
||||
value={terminalFontFamilyId}
|
||||
options={availableFonts.map((f) => ({ value: f.id, label: f.name }))}
|
||||
fonts={availableFonts}
|
||||
onChange={(id) => setTerminalFontFamilyId(id)}
|
||||
className="w-40"
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -608,6 +609,62 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.serverStats")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.serverStats.show")}
|
||||
description={t("settings.terminal.serverStats.show.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.showServerStats}
|
||||
onChange={(v) => updateTerminalSetting("showServerStats", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{terminalSettings.showServerStats && (
|
||||
<SettingRow
|
||||
label={t("settings.terminal.serverStats.refreshInterval")}
|
||||
description={t("settings.terminal.serverStats.refreshInterval.desc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
max={300}
|
||||
value={terminalSettings.serverStatsRefreshInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 5;
|
||||
if (val >= 5 && val <= 300) {
|
||||
updateTerminalSetting("serverStatsRefreshInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.rendering")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.rendering.renderer")}
|
||||
description={t("settings.terminal.rendering.renderer.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.rendererType}
|
||||
options={[
|
||||
{ value: "auto", label: t("settings.terminal.rendering.auto") },
|
||||
{ value: "webgl", label: "WebGL" },
|
||||
{ value: "canvas", label: "Canvas" },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
139
components/sftp-modal/SftpModalDialogs.tsx
Normal file
139
components/sftp-modal/SftpModalDialogs.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface SftpModalDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
handleRename: () => void;
|
||||
isRenaming: boolean;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => void;
|
||||
isChangingPermissions: boolean;
|
||||
}
|
||||
|
||||
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
|
||||
t,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
handleRename,
|
||||
isRenaming,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
isChangingPermissions,
|
||||
}) => (
|
||||
<>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{renameTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder={t("sftp.rename.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
|
||||
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{permissionsTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{(["owner", "group", "others"] as const).map((role) => (
|
||||
<div key={role} className="flex items-center gap-4">
|
||||
<div className="w-16 text-sm font-medium">
|
||||
{t(`sftp.permissions.${role}`)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{(["read", "write", "execute"] as const).map((perm) => (
|
||||
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions[role][perm]}
|
||||
onChange={() => togglePermission(role, perm)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/60">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
|
||||
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
423
components/sftp-modal/SftpModalFileList.tsx
Normal file
423
components/sftp-modal/SftpModalFileList.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React from "react";
|
||||
import { Download, Edit2, Folder, FolderOpen, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { RemoteFile } from "../../types";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import { getFileIcon } from "./fileIcons";
|
||||
|
||||
interface VisibleRow {
|
||||
file: RemoteFile;
|
||||
index: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface SftpModalFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
files: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
dragActive: boolean;
|
||||
loading: boolean;
|
||||
loadingTextContent: boolean;
|
||||
reconnecting: boolean;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
sortField: "name" | "size" | "modified";
|
||||
sortOrder: "asc" | "desc";
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: VisibleRow[];
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
handleSort: (field: "name" | "size" | "modified") => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
handleDownload: (file: RemoteFile) => void;
|
||||
handleDelete: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleEditFile: (file: RemoteFile) => void;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
openPermissionsDialog: (file: RemoteFile) => void;
|
||||
handleNavigate: (path: string) => void;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
handleDownloadSelected: () => void;
|
||||
handleDeleteSelected: () => void;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => void;
|
||||
formatBytes: (bytes: number | string) => string;
|
||||
formatDate: (dateStr: string | number | undefined) => string;
|
||||
}
|
||||
|
||||
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
t,
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
files,
|
||||
selectedFiles,
|
||||
dragActive,
|
||||
loading,
|
||||
loadingTextContent,
|
||||
reconnecting,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
fileListRef,
|
||||
inputRef,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
handleFileListScroll,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileClick,
|
||||
handleFileDoubleClick,
|
||||
handleDownload,
|
||||
handleDelete,
|
||||
handleOpenFile,
|
||||
openFileOpenerDialog,
|
||||
handleEditFile,
|
||||
openRenameDialog,
|
||||
openPermissionsDialog,
|
||||
handleNavigate,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
loadFiles,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<span>{t("sftp.columns.name")}</span>
|
||||
{sortField === "name" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("name", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("size")}
|
||||
>
|
||||
<span>{t("sftp.columns.size")}</span>
|
||||
{sortField === "size" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("size", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("modified")}
|
||||
>
|
||||
<span>{t("sftp.columns.modified")}</span>
|
||||
{sortField === "modified" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("modified", e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">{t("sftp.columns.actions")}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={fileListRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
|
||||
)}
|
||||
onScroll={handleFileListScroll}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{dragActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
|
||||
<Upload size={32} />
|
||||
<span>{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && files.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingTextContent && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sftp.status.loading")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("sftp.reconnecting.desc")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
|
||||
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{visibleRows.map(({ file, index: idx, top }) => {
|
||||
const isNavigableDirectory =
|
||||
file.type === "directory" ||
|
||||
(file.type === "symlink" && file.linkTarget === "directory");
|
||||
const isDownloadableFile =
|
||||
file.type === "file" ||
|
||||
(file.type === "symlink" && file.linkTarget === "file");
|
||||
const isParentEntry = file.name === "..";
|
||||
|
||||
return (
|
||||
<ContextMenu key={file.name}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
data-sftp-modal-row="true"
|
||||
className={cn(
|
||||
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
|
||||
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
|
||||
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
|
||||
)}
|
||||
style={
|
||||
shouldVirtualize
|
||||
? {
|
||||
top,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
}
|
||||
onClick={(e) => handleFileClick(file, idx, e)}
|
||||
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
|
||||
{getFileIcon(
|
||||
file.name,
|
||||
isNavigableDirectory,
|
||||
file.type === "symlink" && !isNavigableDirectory,
|
||||
)}
|
||||
{file.type === "symlink" && (
|
||||
<Link
|
||||
size={10}
|
||||
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate font-medium",
|
||||
file.type === "symlink" && "italic pr-1",
|
||||
)}
|
||||
>
|
||||
{file.name}
|
||||
{file.type === "symlink" && (
|
||||
<span className="sr-only"> (symbolic link)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isNavigableDirectory ? "--" : formatBytes(file.size)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{formatDate(file.lastModified)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isDownloadableFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
title={t("sftp.context.download")}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{!isParentEntry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(file);
|
||||
}}
|
||||
title={t("sftp.context.delete")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{isParentEntry ? (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
segments.pop();
|
||||
const parentPath =
|
||||
segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
||||
handleNavigate(parentPath);
|
||||
}}
|
||||
>
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{isNavigableDirectory && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{isDownloadableFile && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => handleOpenFile(file)}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
|
||||
<MoreHorizontal size={14} className="mr-2" />
|
||||
{t("sftp.context.openWith")}
|
||||
</ContextMenuItem>
|
||||
{!isKnownBinaryFile(file.name) && (
|
||||
<ContextMenuItem onClick={() => handleEditFile(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => openRenameDialog(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.rename")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
|
||||
<Shield size={14} className="mr-2" />
|
||||
{t("sftp.context.permissions")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDelete(file)}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("sftp.context.delete")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleCreateFolder}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCreateFile}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleDownloadSelected}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
61
components/sftp-modal/SftpModalFooter.tsx
Normal file
61
components/sftp-modal/SftpModalFooter.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface SftpModalFooterProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
files: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
loading: boolean;
|
||||
uploading: boolean;
|
||||
onDownloadSelected: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
|
||||
t,
|
||||
files,
|
||||
selectedFiles,
|
||||
loading,
|
||||
uploading,
|
||||
onDownloadSelected,
|
||||
onDeleteSelected,
|
||||
}) => (
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{t("sftp.itemsCount", { count: files.length })}
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span className="text-primary">
|
||||
{t("sftp.selectedCount", { count: selectedFiles.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
|
||||
onClick={onDownloadSelected}
|
||||
>
|
||||
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={onDeleteSelected}
|
||||
>
|
||||
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{loading
|
||||
? t("sftp.status.loading")
|
||||
: uploading
|
||||
? t("sftp.status.uploading")
|
||||
: t("sftp.status.ready")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
253
components/sftp-modal/SftpModalHeader.tsx
Normal file
253
components/sftp-modal/SftpModalHeader.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React from "react";
|
||||
import { ArrowUp, ChevronRight, Home, MoreHorizontal, Plus, RefreshCw, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||
|
||||
interface BreadcrumbPart {
|
||||
part: string;
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
interface SftpModalHeaderProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
host: Host;
|
||||
credentials: { username?: string; hostname: string; port?: number };
|
||||
showEncoding: boolean;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
|
||||
currentPath: string;
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
isAtRoot: boolean;
|
||||
rootLabel: string;
|
||||
isRefreshing: boolean;
|
||||
onUp: () => void;
|
||||
onHome: () => void;
|
||||
onRefresh: () => void;
|
||||
visibleBreadcrumbs: BreadcrumbPart[];
|
||||
hiddenBreadcrumbs: BreadcrumbPart[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbs: string[];
|
||||
onBreadcrumbSelect: (index: number) => void;
|
||||
onRootSelect: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
uploading: boolean;
|
||||
onTriggerUpload: () => void;
|
||||
onCreateFolder: () => void;
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
t,
|
||||
host,
|
||||
credentials,
|
||||
showEncoding,
|
||||
filenameEncoding,
|
||||
onFilenameEncodingChange,
|
||||
currentPath,
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
isAtRoot,
|
||||
rootLabel,
|
||||
isRefreshing,
|
||||
onUp,
|
||||
onHome,
|
||||
onRefresh,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbs,
|
||||
onBreadcrumbSelect,
|
||||
onRootSelect,
|
||||
inputRef,
|
||||
pathInputRef,
|
||||
uploading,
|
||||
onTriggerUpload,
|
||||
onCreateFolder,
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
}) => (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</DialogTitle>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
{showEncoding && (
|
||||
<Select
|
||||
value={filenameEncoding}
|
||||
onValueChange={(value) => onFilenameEncodingChange(value as SftpFilenameEncoding)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[130px] text-xs" title={t("sftp.encoding.label")}>
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" /> {t("sftp.upload")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
175
components/sftp-modal/SftpModalUploadTasks.tsx
Normal file
175
components/sftp-modal/SftpModalUploadTasks.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
import { Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface UploadTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
tasks: UploadTask[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onDismiss }) => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
|
||||
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
|
||||
{tasks.map((task) => {
|
||||
const formatSpeed = (bytesPerSec: number) => {
|
||||
if (bytesPerSec <= 0) return "";
|
||||
if (bytesPerSec >= 1024 * 1024)
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
if (bytesPerSec >= 1024)
|
||||
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
|
||||
return `${Math.round(bytesPerSec)} B/s`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes >= 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const remainingTime =
|
||||
task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0;
|
||||
const remainingStr =
|
||||
remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
: remainingTime > 0
|
||||
? `~${remainingTime}s left`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{task.status === "uploading" && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
<Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<Upload size={14} className="text-green-500" />
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<XCircle size={14} className="text-destructive" />
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<XCircle size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{task.fileName}
|
||||
</span>
|
||||
{task.status === "uploading" && task.speed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(task.speed)}
|
||||
</span>
|
||||
)}
|
||||
{task.status === "uploading" && remainingStr && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{remainingStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === "uploading" || task.status === "pending") && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-150",
|
||||
task.status === "pending"
|
||||
? "bg-muted-foreground/50 animate-pulse w-full"
|
||||
: "bg-primary",
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
task.status === "uploading"
|
||||
? `${task.progress}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
|
||||
{task.status === "uploading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "uploading" && task.totalBytes > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
Completed - {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
Cancelled
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
<div className="text-[10px] text-destructive truncate mt-0.5">
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
{task.status === "pending" && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sftp.task.waiting")}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "pending") && onCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={onCancel}
|
||||
title={t("sftp.action.cancel")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onDismiss(task.id)}
|
||||
title={t("sftp.action.dismiss")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
149
components/sftp-modal/fileIcons.tsx
Normal file
149
components/sftp-modal/fileIcons.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Globe,
|
||||
Lock,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
|
||||
if (isDirectory)
|
||||
return (
|
||||
<Folder
|
||||
size={18}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.2}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
);
|
||||
|
||||
if (isSymlink) {
|
||||
return <ExternalLink size={18} className="text-cyan-500" />;
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (["doc", "docx", "rtf", "odt"].includes(ext))
|
||||
return <FileText size={18} className="text-blue-500" />;
|
||||
if (["xls", "xlsx", "csv", "ods"].includes(ext))
|
||||
return <FileSpreadsheet size={18} className="text-green-500" />;
|
||||
if (["ppt", "pptx", "odp"].includes(ext))
|
||||
return <FileType size={18} className="text-orange-500" />;
|
||||
if (["pdf"].includes(ext))
|
||||
return <FileText size={18} className="text-red-500" />;
|
||||
|
||||
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-500" />;
|
||||
if (["py", "pyc", "pyw"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-400" />;
|
||||
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
|
||||
return <Terminal size={18} className="text-green-400" />;
|
||||
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-600" />;
|
||||
if (["java", "class", "jar"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-600" />;
|
||||
if (["go"].includes(ext))
|
||||
return <FileCode size={18} className="text-cyan-500" />;
|
||||
if (["rs"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["rb"].includes(ext))
|
||||
return <FileCode size={18} className="text-red-400" />;
|
||||
if (["php"].includes(ext))
|
||||
return <FileCode size={18} className="text-purple-500" />;
|
||||
if (["html", "htm", "xhtml"].includes(ext))
|
||||
return <Globe size={18} className="text-orange-500" />;
|
||||
if (["css", "scss", "sass", "less"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-500" />;
|
||||
if (["vue", "svelte"].includes(ext))
|
||||
return <FileCode size={18} className="text-green-500" />;
|
||||
|
||||
if (["json", "json5"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-600" />;
|
||||
if (["xml", "xsl", "xslt"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["yml", "yaml"].includes(ext))
|
||||
return <Settings size={18} className="text-pink-400" />;
|
||||
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
|
||||
return <Settings size={18} className="text-gray-400" />;
|
||||
if (["env"].includes(ext))
|
||||
return <Lock size={18} className="text-yellow-500" />;
|
||||
if (["sql", "sqlite", "db"].includes(ext))
|
||||
return <Database size={18} className="text-blue-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"svg",
|
||||
"ico",
|
||||
"tiff",
|
||||
"tif",
|
||||
"heic",
|
||||
"heif",
|
||||
"avif",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileImage size={18} className="text-purple-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"mp4",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"3gp",
|
||||
"mpeg",
|
||||
"mpg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileVideo size={18} className="text-pink-500" />;
|
||||
|
||||
if (
|
||||
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
|
||||
ext,
|
||||
)
|
||||
)
|
||||
return <FileAudio size={18} className="text-green-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
"tgz",
|
||||
"tbz2",
|
||||
"lz",
|
||||
"lzma",
|
||||
"cab",
|
||||
"iso",
|
||||
"dmg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileArchive size={18} className="text-yellow-600" />;
|
||||
|
||||
return <File size={18} className="text-muted-foreground" />;
|
||||
};
|
||||
108
components/sftp-modal/hooks/useSftpModalCreateDelete.ts
Normal file
108
components/sftp-modal/hooks/useSftpModalCreateDelete.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalCreateDeleteParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalCreateDeleteResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalCreateDelete = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
|
||||
const handleDelete = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
if (file.name === "..") return;
|
||||
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
|
||||
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
await deleteLocalFile(fullPath);
|
||||
} else {
|
||||
await deleteSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
const folderName = prompt(t("sftp.prompt.newFolderName"));
|
||||
if (!folderName) return;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, folderName);
|
||||
if (isLocalSession) {
|
||||
await mkdirLocal(fullPath);
|
||||
} else {
|
||||
await mkdirSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.createFolderFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t]);
|
||||
|
||||
const handleCreateFile = useCallback(async () => {
|
||||
const fileName = prompt(t("sftp.fileName.placeholder"));
|
||||
if (!fileName) return;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileName);
|
||||
if (isLocalSession) {
|
||||
await writeLocalFile(fullPath, new ArrayBuffer(0));
|
||||
} else {
|
||||
try {
|
||||
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
|
||||
} catch {
|
||||
await writeSftp(await ensureSftp(), fullPath, "");
|
||||
}
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.createFileFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, t, writeLocalFile, writeSftp, writeSftpBinary]);
|
||||
|
||||
return { handleDelete, handleCreateFolder, handleCreateFile };
|
||||
};
|
||||
252
components/sftp-modal/hooks/useSftpModalFileActions.ts
Normal file
252
components/sftp-modal/hooks/useSftpModalFileActions.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
|
||||
import { useSftpModalRename } from "./useSftpModalRename";
|
||||
import { useSftpModalPermissions } from "./useSftpModalPermissions";
|
||||
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
|
||||
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileActionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
|
||||
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileActionsResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: () => Promise<void>;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
};
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: RemoteFile | null;
|
||||
setTextEditorTarget: (target: RemoteFile | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (value: string) => void;
|
||||
loadingTextContent: boolean;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileActions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
writeSftpBinary,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
renameSftp,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
|
||||
const { handleDelete, handleCreateFolder, handleCreateFile } =
|
||||
useSftpModalCreateDelete({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
} = useSftpModalRename({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
} = useSftpModalPermissions({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
} = useSftpModalTextEditor({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpModalFileOpener({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
});
|
||||
|
||||
return {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
handleOpenFile,
|
||||
};
|
||||
};
|
||||
154
components/sftp-modal/hooks/useSftpModalFileOpener.ts
Normal file
154
components/sftp-modal/hooks/useSftpModalFileOpener.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileOpenerParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileOpenerResult {
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileOpener = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
|
||||
|
||||
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
|
||||
setFileOpenerTarget(file);
|
||||
setShowFileOpenerDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenFile = useCallback(async (file: RemoteFile) => {
|
||||
const savedOpener = getOpenerForFile(file.name);
|
||||
|
||||
if (savedOpener) {
|
||||
if (savedOpener.openerType === "builtin-editor") {
|
||||
await handleEditFile(file);
|
||||
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openFileOpenerDialog(file);
|
||||
}
|
||||
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(
|
||||
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.name);
|
||||
setOpenerForExtension(ext, openerType, systemApp);
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === "builtin-editor") {
|
||||
await handleEditFile(fileOpenerTarget);
|
||||
} else if (openerType === "system-app" && systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
fileOpenerTarget.name,
|
||||
systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
},
|
||||
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
|
||||
);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
const result = await selectApplication();
|
||||
if (result) {
|
||||
return { path: result.path, name: result.name };
|
||||
}
|
||||
return null;
|
||||
}, [selectApplication]);
|
||||
|
||||
return {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
};
|
||||
};
|
||||
135
components/sftp-modal/hooks/useSftpModalPath.ts
Normal file
135
components/sftp-modal/hooks/useSftpModalPath.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
|
||||
|
||||
interface UseSftpModalPathParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
localHomePath: string | null;
|
||||
onNavigate: (path: string) => void;
|
||||
maxVisibleBreadcrumbParts?: number;
|
||||
}
|
||||
|
||||
interface UseSftpModalPathResult {
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
breadcrumbs: string[];
|
||||
visibleBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbPathAtForIndex: (index: number) => string;
|
||||
rootLabel: string;
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
export const useSftpModalPath = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
localHomePath,
|
||||
onNavigate,
|
||||
maxVisibleBreadcrumbParts = 4,
|
||||
}: UseSftpModalPathParams): UseSftpModalPathResult => {
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editingPathValue, setEditingPathValue] = useState("");
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePathDoubleClick = () => {
|
||||
setEditingPathValue(currentPath);
|
||||
setIsEditingPath(true);
|
||||
setTimeout(() => pathInputRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const handlePathSubmit = () => {
|
||||
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
|
||||
const newPath = editingPathValue.trim() || fallbackPath;
|
||||
setIsEditingPath(false);
|
||||
if (newPath !== currentPath) {
|
||||
if (isLocalSession) {
|
||||
onNavigate(newPath);
|
||||
} else {
|
||||
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handlePathSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
setIsEditingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => getBreadcrumbs(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
|
||||
useMemo(() => {
|
||||
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
|
||||
return {
|
||||
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
|
||||
needsBreadcrumbTruncation: false,
|
||||
};
|
||||
}
|
||||
|
||||
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
|
||||
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
|
||||
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: breadcrumbs.length - lastPartsCount + idx,
|
||||
}));
|
||||
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: idx + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
visibleBreadcrumbs: [...firstPart, ...lastParts],
|
||||
hiddenBreadcrumbs: hidden,
|
||||
needsBreadcrumbTruncation: true,
|
||||
};
|
||||
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
|
||||
|
||||
const breadcrumbPathAtForIndex = useCallback(
|
||||
(index: number) =>
|
||||
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
|
||||
[breadcrumbs, currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootLabel = useMemo(
|
||||
() =>
|
||||
isLocalSession && isWindowsPath(currentPath)
|
||||
? getWindowsDrive(currentPath) ?? "C:"
|
||||
: "/",
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootPath = useMemo(
|
||||
() => getRootPath(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
return {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
pathInputRef,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
breadcrumbs,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbPathAtForIndex,
|
||||
rootLabel,
|
||||
rootPath,
|
||||
};
|
||||
};
|
||||
189
components/sftp-modal/hooks/useSftpModalPermissions.ts
Normal file
189
components/sftp-modal/hooks/useSftpModalPermissions.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalPermissionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
|
||||
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface UseSftpModalPermissionsResult {
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalPermissions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
|
||||
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
|
||||
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsState>({
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
});
|
||||
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
|
||||
|
||||
const parsePermissions = useCallback((perms: string | undefined) => {
|
||||
const defaultPerms = {
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
};
|
||||
if (!perms) return defaultPerms;
|
||||
|
||||
if (/^[0-7]{3,4}$/.test(perms)) {
|
||||
const octal = perms.length === 4 ? perms.slice(1) : perms;
|
||||
const ownerBits = parseInt(octal[0], 10);
|
||||
const groupBits = parseInt(octal[1], 10);
|
||||
const othersBits = parseInt(octal[2], 10);
|
||||
return {
|
||||
owner: {
|
||||
read: (ownerBits & 4) !== 0,
|
||||
write: (ownerBits & 2) !== 0,
|
||||
execute: (ownerBits & 1) !== 0,
|
||||
},
|
||||
group: {
|
||||
read: (groupBits & 4) !== 0,
|
||||
write: (groupBits & 2) !== 0,
|
||||
execute: (groupBits & 1) !== 0,
|
||||
},
|
||||
others: {
|
||||
read: (othersBits & 4) !== 0,
|
||||
write: (othersBits & 2) !== 0,
|
||||
execute: (othersBits & 1) !== 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pStr = perms.length === 10 ? perms.slice(1) : perms;
|
||||
if (pStr.length >= 9) {
|
||||
return {
|
||||
owner: {
|
||||
read: pStr[0] === "r",
|
||||
write: pStr[1] === "w",
|
||||
execute: pStr[2] === "x" || pStr[2] === "s",
|
||||
},
|
||||
group: {
|
||||
read: pStr[3] === "r",
|
||||
write: pStr[4] === "w",
|
||||
execute: pStr[5] === "x" || pStr[5] === "s",
|
||||
},
|
||||
others: {
|
||||
read: pStr[6] === "r",
|
||||
write: pStr[7] === "w",
|
||||
execute: pStr[8] === "x" || pStr[8] === "t",
|
||||
},
|
||||
};
|
||||
}
|
||||
return defaultPerms;
|
||||
}, []);
|
||||
|
||||
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
|
||||
if (isLocalSession) {
|
||||
toast.error("Permissions not available for local files", "SFTP");
|
||||
return;
|
||||
}
|
||||
setPermissionsTarget(file);
|
||||
|
||||
let permsStr = file.permissions;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const stat = await statSftp(await ensureSftp(), fullPath);
|
||||
if (stat.permissions) {
|
||||
permsStr = stat.permissions;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch file permissions:", e);
|
||||
}
|
||||
|
||||
setPermissions(parsePermissions(permsStr));
|
||||
setShowPermissionsDialog(true);
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
|
||||
|
||||
const togglePermission = useCallback(
|
||||
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], [perm]: !prev[role][perm] },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getOctalPermissions = useCallback(() => {
|
||||
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
|
||||
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
|
||||
}, [permissions]);
|
||||
|
||||
const getSymbolicPermissions = useCallback(() => {
|
||||
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
|
||||
return (
|
||||
getSym(permissions.owner) +
|
||||
getSym(permissions.group) +
|
||||
getSym(permissions.others)
|
||||
);
|
||||
}, [permissions]);
|
||||
|
||||
const handleSavePermissions = useCallback(async () => {
|
||||
if (!permissionsTarget || isChangingPermissions) return;
|
||||
setIsChangingPermissions(true);
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, permissionsTarget.name);
|
||||
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
|
||||
setShowPermissionsDialog(false);
|
||||
setPermissionsTarget(null);
|
||||
await loadFiles(currentPath, { force: true });
|
||||
toast.success(t("sftp.permissions.success"), "SFTP");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.permissions.failed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsChangingPermissions(false);
|
||||
}
|
||||
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
|
||||
|
||||
return {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
};
|
||||
};
|
||||
85
components/sftp-modal/hooks/useSftpModalRename.ts
Normal file
85
components/sftp-modal/hooks/useSftpModalRename.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalRenameParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalRenameResult {
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalRename = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
const openRenameDialog = useCallback((file: RemoteFile) => {
|
||||
setRenameTarget(file);
|
||||
setRenameName(file.name);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renameTarget || !renameName.trim() || isRenaming) return;
|
||||
if (renameName.trim() === renameTarget.name) {
|
||||
setShowRenameDialog(false);
|
||||
return;
|
||||
}
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
const oldPath = joinPath(currentPath, renameTarget.name);
|
||||
const newPath = joinPath(currentPath, renameName.trim());
|
||||
if (isLocalSession) {
|
||||
toast.error("Local rename not implemented", "SFTP");
|
||||
} else {
|
||||
await renameSftp(await ensureSftp(), oldPath, newPath);
|
||||
}
|
||||
setShowRenameDialog(false);
|
||||
setRenameTarget(null);
|
||||
setRenameName("");
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
|
||||
|
||||
return {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
};
|
||||
};
|
||||
99
components/sftp-modal/hooks/useSftpModalSelection.ts
Normal file
99
components/sftp-modal/hooks/useSftpModalSelection.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
interface UseSftpModalSelectionParams {
|
||||
files: RemoteFile[];
|
||||
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||
currentPath: string;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
onNavigate: (path: string) => void;
|
||||
onOpenFile: (file: RemoteFile) => void;
|
||||
onNavigateUp: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSelectionResult {
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSelection = ({
|
||||
files,
|
||||
setSelectedFiles,
|
||||
currentPath,
|
||||
joinPath,
|
||||
onNavigate,
|
||||
onOpenFile,
|
||||
onNavigateUp,
|
||||
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(file: RemoteFile, index: number, e: React.MouseEvent) => {
|
||||
if (file.name === "..") return;
|
||||
|
||||
if (file.type === "directory") {
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(file.name)) {
|
||||
next.delete(file.name);
|
||||
} else {
|
||||
next.add(file.name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
lastSelectedIndexRef.current = index;
|
||||
} else {
|
||||
setSelectedFiles(new Set([file.name]));
|
||||
lastSelectedIndexRef.current = index;
|
||||
}
|
||||
},
|
||||
[files, setSelectedFiles],
|
||||
);
|
||||
|
||||
const handleFileDoubleClick = useCallback(
|
||||
(file: RemoteFile) => {
|
||||
if (file.name === "..") {
|
||||
onNavigateUp();
|
||||
return;
|
||||
}
|
||||
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
|
||||
onNavigate(joinPath(currentPath, file.name));
|
||||
} else {
|
||||
onOpenFile(file);
|
||||
}
|
||||
},
|
||||
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
|
||||
);
|
||||
|
||||
return { handleFileClick, handleFileDoubleClick };
|
||||
};
|
||||
392
components/sftp-modal/hooks/useSftpModalSession.ts
Normal file
392
components/sftp-modal/hooks/useSftpModalSession.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Host, RemoteFile } from "../../../types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalSessionParams {
|
||||
open: boolean;
|
||||
host: Host;
|
||||
credentials: {
|
||||
username?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
openSftp: (params: {
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sudo?: boolean;
|
||||
}) => Promise<string>;
|
||||
closeSftp: (sftpId: string) => Promise<void>;
|
||||
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
listLocalDir: (path: string) => Promise<RemoteFile[]>;
|
||||
getHomeDir: () => Promise<string | null>;
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSessionResult {
|
||||
currentPath: string;
|
||||
setCurrentPath: (path: string) => void;
|
||||
files: RemoteFile[];
|
||||
setFiles: (files: RemoteFile[]) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
reconnecting: boolean;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
closeSftpSession: () => Promise<void>;
|
||||
localHomeRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalSession = ({
|
||||
open,
|
||||
host,
|
||||
credentials,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
t,
|
||||
openSftp,
|
||||
closeSftp,
|
||||
listSftp,
|
||||
listLocalDir,
|
||||
getHomeDir,
|
||||
onClearSelection,
|
||||
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
const reconnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
const DIR_CACHE_TTL_MS = 10_000;
|
||||
const dirCacheRef = useRef<
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
|
||||
const ensureSftp = useCallback(async () => {
|
||||
if (isLocalSession) throw new Error("Local session does not use SFTP");
|
||||
if (sftpIdRef.current) return sftpIdRef.current;
|
||||
const sftpId = await openSftp({
|
||||
sessionId: `sftp-modal-${host.id}`,
|
||||
hostname: credentials.hostname,
|
||||
username: credentials.username || "root",
|
||||
port: credentials.port || 22,
|
||||
password: credentials.password,
|
||||
privateKey: credentials.privateKey,
|
||||
certificate: credentials.certificate,
|
||||
passphrase: credentials.passphrase,
|
||||
publicKey: credentials.publicKey,
|
||||
keyId: credentials.keyId,
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
return sftpId;
|
||||
}, [
|
||||
isLocalSession,
|
||||
host.id,
|
||||
credentials.hostname,
|
||||
credentials.username,
|
||||
credentials.port,
|
||||
credentials.password,
|
||||
credentials.privateKey,
|
||||
credentials.certificate,
|
||||
credentials.passphrase,
|
||||
credentials.publicKey,
|
||||
credentials.keyId,
|
||||
credentials.keySource,
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
const closeSftpSession = useCallback(async () => {
|
||||
if (!isLocalSession && sftpIdRef.current) {
|
||||
try {
|
||||
await closeSftp(sftpIdRef.current);
|
||||
} catch {
|
||||
// Silently ignore close errors - connection may already be closed
|
||||
}
|
||||
}
|
||||
sftpIdRef.current = null;
|
||||
}, [closeSftp, isLocalSession]);
|
||||
|
||||
const isSessionError = useCallback((err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("client disconnected")
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSessionError = useCallback(async () => {
|
||||
if (reconnectingRef.current) return;
|
||||
reconnectingRef.current = true;
|
||||
setReconnecting(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
try {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
if (sftpIdRef.current) {
|
||||
try {
|
||||
await closeSftp(sftpIdRef.current);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sftpIdRef.current = null;
|
||||
}
|
||||
await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
|
||||
err,
|
||||
);
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftp, ensureSftp, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
const requestId = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
onClearSelection();
|
||||
|
||||
try {
|
||||
if (isLocalSession) {
|
||||
const list = await listLocalDir(path);
|
||||
if (requestId === loadSeqRef.current) {
|
||||
setFiles(list);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${host.id}::${path}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh =
|
||||
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (cached && isFresh && !options?.force) {
|
||||
setFiles(cached.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, path);
|
||||
if (requestId !== loadSeqRef.current) return;
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isLocalSession && isSessionError(e) && files.length > 0) {
|
||||
logger.info("[SFTP] Session lost, attempting to reconnect...");
|
||||
handleSessionError();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("Failed to load files", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
if (loadSeqRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const cacheKey = `${host.id}::${currentPath}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (!isFresh) {
|
||||
setFiles([]);
|
||||
onClearSelection();
|
||||
}
|
||||
}, [currentPath, host.id, onClearSelection, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
|
||||
initializedRef.current = true;
|
||||
lastInitialPathRef.current = initialPath;
|
||||
onClearSelection();
|
||||
setLoading(true);
|
||||
|
||||
if (isLocalSession) {
|
||||
(async () => {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
const startPath = initialPath || homePath || "/";
|
||||
try {
|
||||
const list = await listLocalDir(startPath);
|
||||
setCurrentPath(startPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${startPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, homePath || "/");
|
||||
setCurrentPath(homePath || "/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch {
|
||||
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, "/");
|
||||
setCurrentPath("/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::/`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Failed to load root directory", e);
|
||||
toast.error(t("sftp.error.loadFailed"), "SFTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void loadFiles(currentPath);
|
||||
} else {
|
||||
loadSeqRef.current += 1;
|
||||
void closeSftpSession();
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [
|
||||
closeSftpSession,
|
||||
currentPath,
|
||||
ensureSftp,
|
||||
getHomeDir,
|
||||
host.id,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
listLocalDir,
|
||||
listSftp,
|
||||
loadFiles,
|
||||
onClearSelection,
|
||||
open,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void closeSftpSession();
|
||||
};
|
||||
}, [closeSftpSession]);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
localHomeRef,
|
||||
};
|
||||
};
|
||||
76
components/sftp-modal/hooks/useSftpModalSorting.ts
Normal file
76
components/sftp-modal/hooks/useSftpModalSorting.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
|
||||
export type SortField = "name" | "size" | "modified";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
interface UseSftpModalSortingResult {
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const [columnWidths, setColumnWidths] = useState({
|
||||
name: 45,
|
||||
size: 15,
|
||||
modified: 25,
|
||||
actions: 15,
|
||||
});
|
||||
|
||||
const resizingRef = useRef<{
|
||||
field: string;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const diff = e.clientX - resizingRef.current.startX;
|
||||
const newWidth = Math.max(
|
||||
10,
|
||||
Math.min(60, resizingRef.current.startWidth + diff / 5),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizingRef.current!.field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (field: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
startWidth: columnWidths[field as keyof typeof columnWidths],
|
||||
};
|
||||
document.addEventListener("mousemove", handleResizeMove);
|
||||
document.addEventListener("mouseup", handleResizeEnd);
|
||||
};
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
columnWidths,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
};
|
||||
};
|
||||
87
components/sftp-modal/hooks/useSftpModalTextEditor.ts
Normal file
87
components/sftp-modal/hooks/useSftpModalTextEditor.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalTextEditorParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalTextEditorResult {
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: RemoteFile | null;
|
||||
setTextEditorTarget: (target: RemoteFile | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (value: string) => void;
|
||||
loadingTextContent: boolean;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalTextEditor = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
const handleEditFile = useCallback(async (file: RemoteFile) => {
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget(file);
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
|
||||
|
||||
const handleSaveTextFile = useCallback(async (content: string) => {
|
||||
if (!textEditorTarget) return;
|
||||
const fullPath = joinPath(currentPath, textEditorTarget.name);
|
||||
if (isLocalSession) {
|
||||
const encoder = new TextEncoder();
|
||||
await writeLocalFile(fullPath, encoder.encode(content).buffer);
|
||||
} else {
|
||||
await writeSftp(await ensureSftp(), fullPath, content);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
|
||||
|
||||
return {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
};
|
||||
};
|
||||
602
components/sftp-modal/hooks/useSftpModalTransfers.ts
Normal file
602
components/sftp-modal/hooks/useSftpModalTransfers.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import React, { useCallback, useState, useRef, useMemo } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadTaskInfo,
|
||||
UploadProgress,
|
||||
} from "../../../lib/uploadService";
|
||||
|
||||
interface UploadTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
speed: number;
|
||||
startTime: number;
|
||||
error?: string;
|
||||
isDirectory?: boolean;
|
||||
fileCount?: number;
|
||||
completedCount?: number;
|
||||
}
|
||||
|
||||
interface UseSftpModalTransfersParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinaryWithProgress: (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
taskId: string,
|
||||
onProgress: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete: () => void,
|
||||
onError: (error: string) => void,
|
||||
) => Promise<boolean>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
|
||||
startStreamTransfer?: (
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
cancelTransfer?: (transferId: string) => Promise<void>;
|
||||
setLoading: (loading: boolean) => void;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalTransfersResult {
|
||||
uploading: boolean;
|
||||
uploadTasks: UploadTask[];
|
||||
dragActive: boolean;
|
||||
handleDownload: (file: RemoteFile) => Promise<void>;
|
||||
handleUploadMultiple: (fileList: FileList) => Promise<void>;
|
||||
handleUploadFromDrop: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
cancelUpload: () => Promise<void>;
|
||||
dismissTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalTransfers = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinaryWithProgress,
|
||||
writeSftpBinary,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
setLoading,
|
||||
t,
|
||||
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
// Upload controller for cancellation support
|
||||
const uploadControllerRef = useRef<UploadController | null>(null);
|
||||
|
||||
// Cached SFTP ID to avoid multiple calls to ensureSftp
|
||||
const cachedSftpIdRef = useRef<string | null>(null);
|
||||
|
||||
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
|
||||
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
setLoading(true);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath)
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
|
||||
);
|
||||
|
||||
// Create upload bridge that adapts the modal's functions to the service interface
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
return {
|
||||
writeLocalFile,
|
||||
mkdirLocal,
|
||||
mkdirSftp: async (sftpId: string, path: string) => {
|
||||
await mkdirSftp(sftpId, path);
|
||||
},
|
||||
writeSftpBinary: async (sftpId: string, path: string, data: ArrayBuffer) => {
|
||||
await writeSftpBinary(sftpId, path, data);
|
||||
},
|
||||
writeSftpBinaryWithProgress: async (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
taskId: string,
|
||||
onProgress: (transferred: number, total: number, speed: number) => void,
|
||||
_onComplete?: () => void,
|
||||
_onError?: (error: string) => void
|
||||
) => {
|
||||
try {
|
||||
const result = await writeSftpBinaryWithProgress(
|
||||
sftpId,
|
||||
path,
|
||||
data,
|
||||
taskId,
|
||||
onProgress,
|
||||
() => { },
|
||||
() => { }
|
||||
);
|
||||
// Check if this transfer was cancelled
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(taskId);
|
||||
}
|
||||
return { success: result, cancelled: wasCancelled };
|
||||
} catch (error) {
|
||||
// Check if this was a user-initiated cancellation
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(taskId);
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
// Real error - propagate it by re-throwing
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer: startStreamTransfer ? async (
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError
|
||||
) => {
|
||||
try {
|
||||
const result = await startStreamTransfer(options, onProgress, onComplete, onError);
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(options.transferId);
|
||||
}
|
||||
// Handle case where result might be undefined (bridge not available)
|
||||
if (!result) {
|
||||
return { transferId: options.transferId, error: 'Stream transfer not available' };
|
||||
}
|
||||
return { ...result, cancelled: wasCancelled };
|
||||
} catch (error) {
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(options.transferId);
|
||||
return { transferId: options.transferId, cancelled: true };
|
||||
}
|
||||
return { transferId: options.transferId, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
} : undefined,
|
||||
cancelTransfer,
|
||||
};
|
||||
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
|
||||
|
||||
// Create upload callbacks
|
||||
const createUploadCallbacks = useCallback((): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
const scanningTask: UploadTask = {
|
||||
id: taskId,
|
||||
fileName: "Scanning files...",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, scanningTask]);
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
},
|
||||
onTaskCreated: (task: UploadTaskInfo) => {
|
||||
const uploadTask: UploadTask = {
|
||||
id: task.id,
|
||||
fileName: task.displayName,
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
totalBytes: task.totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
fileCount: task.fileCount,
|
||||
completedCount: 0,
|
||||
};
|
||||
// Filter out any pending scanning tasks before adding the real task.
|
||||
// This ensures that even if onScanningEnd's state update hasn't been applied yet
|
||||
// (due to React state batching), the scanning placeholder will still be removed.
|
||||
setUploadTasks(prev => [
|
||||
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
|
||||
uploadTask
|
||||
]);
|
||||
},
|
||||
onTaskProgress: (taskId: string, progress: UploadProgress) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId && task.status === "uploading"
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: progress.transferred,
|
||||
progress: progress.percent,
|
||||
speed: progress.speed,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
// Any error marks the task as failed
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "failed" as const,
|
||||
error,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
|
||||
// Auto-clear failed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, 3000);
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "cancelled" as const,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
// Auto-clear cancelled tasks after 2 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, 2000);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUploadMultiple = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
console.log('[useSftpModalTransfers] handleUploadMultiple called', {
|
||||
length: fileList.length,
|
||||
currentPath,
|
||||
isLocalSession
|
||||
});
|
||||
if (fileList.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
fileList,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
|
||||
// Auto-clear completed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
const handleUploadFromDrop = useCallback(
|
||||
async (dataTransfer: DataTransfer) => {
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
|
||||
// Auto-clear completed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
// Handle upload from File array (used by file input after copying files)
|
||||
const handleUploadFromFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
console.log('[useSftpModalTransfers] handleUploadFromFiles called', {
|
||||
length: files.length,
|
||||
currentPath,
|
||||
isLocalSession
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
|
||||
// Auto-clear completed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log('[useSftpModalTransfers] handleFileSelect called', {
|
||||
files: e.target.files,
|
||||
length: e.target.files?.length
|
||||
});
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
console.log('[useSftpModalTransfers] Starting upload for', e.target.files.length, 'files');
|
||||
// Copy the files before clearing the input, because clearing the input
|
||||
// will also clear the FileList reference
|
||||
const files = Array.from(e.target.files);
|
||||
// Clear input first to allow selecting the same file again
|
||||
e.target.value = "";
|
||||
// Now start the upload with the copied files
|
||||
void handleUploadFromFiles(files);
|
||||
} else {
|
||||
e.target.value = "";
|
||||
}
|
||||
},
|
||||
[handleUploadFromFiles],
|
||||
);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
void handleUploadFromDrop(e.dataTransfer);
|
||||
}
|
||||
},
|
||||
[handleUploadFromDrop],
|
||||
);
|
||||
|
||||
const cancelUpload = useCallback(async () => {
|
||||
console.log('[useSftpModalTransfers] cancelUpload called');
|
||||
const controller = uploadControllerRef.current;
|
||||
if (controller) {
|
||||
// Mark all active transfer IDs as cancelled before calling cancel
|
||||
const activeIds = controller.getActiveTransferIds();
|
||||
console.log('[useSftpModalTransfers] Active transfer IDs:', activeIds);
|
||||
for (const id of activeIds) {
|
||||
cancelledTransferIdsRef.current.add(id);
|
||||
}
|
||||
await controller.cancel();
|
||||
console.log('[useSftpModalTransfers] controller.cancel() completed');
|
||||
} else {
|
||||
console.log('[useSftpModalTransfers] No controller found');
|
||||
}
|
||||
|
||||
// Always clear all uploading/pending tasks immediately, even without controller
|
||||
setUploadTasks(prev => {
|
||||
const hasActiveTasks = prev.some(t => t.status === "uploading" || t.status === "pending");
|
||||
if (!hasActiveTasks) return prev;
|
||||
|
||||
return prev.map(task =>
|
||||
task.status === "uploading" || task.status === "pending"
|
||||
? { ...task, status: "cancelled" as const, speed: 0 }
|
||||
: task
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-clear cancelled tasks after 2 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.status !== "cancelled"));
|
||||
}, 2000);
|
||||
|
||||
// Also reset uploading state
|
||||
setUploading(false);
|
||||
}, []);
|
||||
|
||||
const dismissTask = useCallback((taskId: string) => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadTasks,
|
||||
dragActive,
|
||||
handleDownload,
|
||||
handleUploadMultiple,
|
||||
handleUploadFromDrop,
|
||||
handleFileSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
dismissTask,
|
||||
};
|
||||
};
|
||||
123
components/sftp-modal/hooks/useSftpModalVirtualList.ts
Normal file
123
components/sftp-modal/hooks/useSftpModalVirtualList.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
interface UseSftpModalVirtualListParams {
|
||||
open: boolean;
|
||||
sortedFiles: RemoteFile[];
|
||||
}
|
||||
|
||||
interface UseSftpModalVirtualListResult {
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
rowHeight: number;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: { file: RemoteFile; index: number; top: number }[];
|
||||
}
|
||||
|
||||
export const useSftpModalVirtualList = ({
|
||||
open,
|
||||
sortedFiles,
|
||||
}: UseSftpModalVirtualListParams): UseSftpModalVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [rowHeight, setRowHeight] = useState(40);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !open) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [open, sortedFiles.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !open || sortedFiles.length === 0) return;
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const rowElement = container.querySelector(
|
||||
'[data-sftp-modal-row="true"]',
|
||||
) as HTMLElement | null;
|
||||
if (!rowElement) return;
|
||||
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
|
||||
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
|
||||
setRowHeight(nextHeight);
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [open, rowHeight, sortedFiles.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileListScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
const nextTop = e.currentTarget.scrollTop;
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
setScrollTop(nextTop);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
|
||||
const overscan = 6;
|
||||
const canVirtualize = open && viewportHeight > 0 && rowHeight > 0;
|
||||
const shouldVirtualizeLocal = canVirtualize && sortedFiles.length > 50;
|
||||
const totalHeightLocal = shouldVirtualizeLocal
|
||||
? sortedFiles.length * rowHeight
|
||||
: 0;
|
||||
const startIndex = shouldVirtualizeLocal
|
||||
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
||||
: 0;
|
||||
const endIndex = shouldVirtualizeLocal
|
||||
? Math.min(
|
||||
sortedFiles.length - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
|
||||
)
|
||||
: sortedFiles.length - 1;
|
||||
const visibleRowsLocal = shouldVirtualizeLocal
|
||||
? sortedFiles
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.map((file, idx) => ({
|
||||
file,
|
||||
index: startIndex + idx,
|
||||
top: (startIndex + idx) * rowHeight,
|
||||
}))
|
||||
: sortedFiles.map((file, index) => ({
|
||||
file,
|
||||
index,
|
||||
top: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
shouldVirtualize: shouldVirtualizeLocal,
|
||||
totalHeight: totalHeightLocal,
|
||||
visibleRows: visibleRowsLocal,
|
||||
};
|
||||
}, [open, rowHeight, scrollTop, sortedFiles, viewportHeight]);
|
||||
|
||||
return {
|
||||
fileListRef,
|
||||
rowHeight,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
};
|
||||
};
|
||||
83
components/sftp-modal/pathUtils.ts
Normal file
83
components/sftp-modal/pathUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
|
||||
|
||||
export const normalizeWindowsRoot = (path: string): string => {
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
|
||||
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const joinPath = (base: string, name: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(base)) {
|
||||
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
export const isRootPath = (path: string, isLocalSession: boolean): boolean => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
|
||||
}
|
||||
return path === "/";
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const drive = normalized.slice(0, 2);
|
||||
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
|
||||
return `${drive}\\`;
|
||||
}
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
if (parts.length <= 1) return `${drive}\\`;
|
||||
parts.pop();
|
||||
return `${drive}\\${parts.join("\\")}`;
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.length ? `/${parts.join("/")}` : "/";
|
||||
};
|
||||
|
||||
export const getRootPath = (path: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const drive = path.replace(/\//g, "\\").slice(0, 2);
|
||||
return `${drive}\\`;
|
||||
}
|
||||
return "/";
|
||||
};
|
||||
|
||||
export const getWindowsDrive = (path: string): string | null => {
|
||||
if (!isWindowsPath(path)) return null;
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null;
|
||||
};
|
||||
|
||||
export const getBreadcrumbs = (path: string, isLocalSession: boolean): string[] => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
return parts;
|
||||
}
|
||||
return path === "/" ? [] : path.split("/").filter(Boolean);
|
||||
};
|
||||
|
||||
export const breadcrumbPathAt = (
|
||||
breadcrumbs: string[],
|
||||
idx: number,
|
||||
currentPath: string,
|
||||
isLocalSession: boolean,
|
||||
): string => {
|
||||
if (isLocalSession) {
|
||||
const drive = getWindowsDrive(currentPath);
|
||||
if (drive) {
|
||||
const rest = breadcrumbs.slice(0, idx + 1).join("\\");
|
||||
return rest ? `${drive}\\${rest}` : `${drive}\\`;
|
||||
}
|
||||
}
|
||||
return "/" + breadcrumbs.slice(0, idx + 1).join("/");
|
||||
};
|
||||
16
components/sftp-modal/utils.ts
Normal file
16
components/sftp-modal/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const formatBytes = (bytes: number | string): string => {
|
||||
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(numBytes) || numBytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
|
||||
const size = numBytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
export const formatDate = (dateStr: string | number | undefined): string => {
|
||||
if (!dateStr) return "--";
|
||||
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return String(dateStr);
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import { Host, SftpFileEntry } from "../../types";
|
||||
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
|
||||
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
@@ -16,6 +16,7 @@ export interface SftpPaneCallbacks {
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
|
||||
onOpenEntry: (entry: SftpFileEntry) => void;
|
||||
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
|
||||
onRangeSelect: (fileNames: string[]) => void;
|
||||
|
||||
@@ -38,9 +38,11 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
|
||||
const filteredHosts = useMemo(() => {
|
||||
const term = hostSearch.trim().toLowerCase();
|
||||
return hosts.filter(h =>
|
||||
!term ||
|
||||
// Filter out serial hosts - SFTP is not supported for serial connections
|
||||
h.protocol !== "serial" &&
|
||||
(!term ||
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term)
|
||||
h.hostname.toLowerCase().includes(term))
|
||||
).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [hosts, hostSearch]);
|
||||
const sideLabel = side === 'left' ? t('common.left') : t('common.right');
|
||||
|
||||
196
components/sftp/SftpOverlays.tsx
Normal file
196
components/sftp/SftpOverlays.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from "react";
|
||||
import type { Host, SftpFileEntry } from "../../types";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
|
||||
import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import { Button } from "../ui/button";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog, SftpTransferItem } from "./index";
|
||||
|
||||
type SftpState = ReturnType<typeof useSftpState>;
|
||||
|
||||
interface SftpOverlaysProps {
|
||||
hosts: Host[];
|
||||
sftp: SftpState;
|
||||
visibleTransfers: SftpState["transfers"];
|
||||
showHostPickerLeft: boolean;
|
||||
showHostPickerRight: boolean;
|
||||
hostSearchLeft: string;
|
||||
hostSearchRight: string;
|
||||
setShowHostPickerLeft: (open: boolean) => void;
|
||||
setShowHostPickerRight: (open: boolean) => void;
|
||||
setHostSearchLeft: (value: string) => void;
|
||||
setHostSearchRight: (value: string) => void;
|
||||
handleHostSelectLeft: (host: Host | "local") => void;
|
||||
handleHostSelectRight: (host: Host | "local") => void;
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
|
||||
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right" } | null) => void;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
setTextEditorTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (content: string) => void;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
|
||||
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
hosts,
|
||||
sftp,
|
||||
visibleTransfers,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
hostSearchRight,
|
||||
setShowHostPickerLeft,
|
||||
setShowHostPickerRight,
|
||||
setHostSearchLeft,
|
||||
setHostSearchRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
handleSaveTextFile,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Host pickers for adding new tabs */}
|
||||
<SftpHostPicker
|
||||
open={showHostPickerLeft}
|
||||
onOpenChange={setShowHostPickerLeft}
|
||||
hosts={hosts}
|
||||
side="left"
|
||||
hostSearch={hostSearchLeft}
|
||||
onHostSearchChange={setHostSearchLeft}
|
||||
onSelectLocal={() => handleHostSelectLeft("local")}
|
||||
onSelectHost={handleHostSelectLeft}
|
||||
/>
|
||||
<SftpHostPicker
|
||||
open={showHostPickerRight}
|
||||
onOpenChange={setShowHostPickerRight}
|
||||
hosts={hosts}
|
||||
side="right"
|
||||
hostSearch={hostSearchRight}
|
||||
onHostSearchChange={setHostSearchRight}
|
||||
onSelectLocal={() => handleHostSelectRight("local")}
|
||||
onSelectHost={handleHostSelectRight}
|
||||
/>
|
||||
|
||||
{/* Transfer status area - shows folder uploads and file transfers */}
|
||||
{sftp.transfers.length > 0 && (
|
||||
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
|
||||
<span className="font-medium">
|
||||
Transfers
|
||||
{sftp.activeTransfersCount > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
({sftp.activeTransfersCount} active)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{sftp.transfers.some(
|
||||
(t) => t.status === "completed" || t.status === "cancelled",
|
||||
) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={sftp.clearCompletedTransfers}
|
||||
>
|
||||
Clear completed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-40 overflow-auto">
|
||||
{visibleTransfers.map((task) => (
|
||||
<SftpTransferItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onCancel={() => {
|
||||
// External uploads use a different cancel mechanism
|
||||
if (task.sourceConnectionId === "external") {
|
||||
sftp.cancelExternalUpload();
|
||||
}
|
||||
sftp.cancelTransfer(task.id);
|
||||
}}
|
||||
onRetry={() => sftp.retryTransfer(task.id)}
|
||||
onDismiss={() => sftp.dismissTransfer(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SftpConflictDialog
|
||||
conflicts={sftp.conflicts}
|
||||
onResolve={sftp.resolveConflict}
|
||||
formatFileSize={sftp.formatFileSize}
|
||||
/>
|
||||
|
||||
<SftpPermissionsDialog
|
||||
open={!!permissionsState}
|
||||
onOpenChange={(open) => !open && setPermissionsState(null)}
|
||||
file={permissionsState?.file ?? null}
|
||||
onSave={(file, permissions) => {
|
||||
if (permissionsState) {
|
||||
const fullPath = sftp.joinPath(
|
||||
permissionsState.side === "left"
|
||||
? sftp.leftPane.connection?.currentPath || ""
|
||||
: sftp.rightPane.connection?.currentPath || "",
|
||||
file.name,
|
||||
);
|
||||
sftp.changePermissions(
|
||||
permissionsState.side,
|
||||
fullPath,
|
||||
permissions,
|
||||
);
|
||||
}
|
||||
setPermissionsState(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Text Editor Modal */}
|
||||
<TextEditorModal
|
||||
open={showTextEditor}
|
||||
onClose={() => {
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}}
|
||||
fileName={textEditorTarget?.file.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
<FileOpenerDialog
|
||||
open={showFileOpenerDialog}
|
||||
onClose={() => {
|
||||
setShowFileOpenerDialog(false);
|
||||
setFileOpenerTarget(null);
|
||||
}}
|
||||
fileName={fileOpenerTarget?.file.name || ""}
|
||||
onSelect={handleFileOpenerSelect}
|
||||
onSelectSystemApp={handleSelectSystemApp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
313
components/sftp/SftpPaneDialogs.tsx
Normal file
313
components/sftp/SftpPaneDialogs.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React from "react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
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 { SftpHostPicker } from "./index";
|
||||
import type { Host } from "../../types";
|
||||
|
||||
interface SftpPaneDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
// New folder
|
||||
showNewFolderDialog: boolean;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
newFolderName: string;
|
||||
setNewFolderName: (value: string) => void;
|
||||
handleCreateFolder: () => void;
|
||||
isCreating: boolean;
|
||||
// New file
|
||||
showNewFileDialog: boolean;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
newFileName: string;
|
||||
setNewFileName: (value: string) => void;
|
||||
fileNameError: string | null;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
handleCreateFile: () => void;
|
||||
isCreatingFile: boolean;
|
||||
// Overwrite confirm
|
||||
showOverwriteConfirm: boolean;
|
||||
setShowOverwriteConfirm: (open: boolean) => void;
|
||||
overwriteTarget: string | null;
|
||||
handleOverwriteConfirm: () => void;
|
||||
// Rename
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
handleRename: () => void;
|
||||
isRenaming: boolean;
|
||||
// Delete
|
||||
showDeleteConfirm: boolean;
|
||||
setShowDeleteConfirm: (open: boolean) => void;
|
||||
deleteTargets: string[];
|
||||
handleDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
// Host picker (connected view)
|
||||
showHostPicker: boolean;
|
||||
setShowHostPicker: (open: boolean) => void;
|
||||
hosts: Host[];
|
||||
side: "left" | "right";
|
||||
hostSearch: string;
|
||||
setHostSearch: (value: string) => void;
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
t,
|
||||
showNewFolderDialog,
|
||||
setShowNewFolderDialog,
|
||||
newFolderName,
|
||||
setNewFolderName,
|
||||
handleCreateFolder,
|
||||
isCreating,
|
||||
showNewFileDialog,
|
||||
setShowNewFileDialog,
|
||||
newFileName,
|
||||
setNewFileName,
|
||||
fileNameError,
|
||||
setFileNameError,
|
||||
handleCreateFile,
|
||||
isCreatingFile,
|
||||
showOverwriteConfirm,
|
||||
setShowOverwriteConfirm,
|
||||
overwriteTarget,
|
||||
handleOverwriteConfirm,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameName,
|
||||
setRenameName,
|
||||
handleRename,
|
||||
isRenaming,
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
deleteTargets,
|
||||
handleDelete,
|
||||
isDeleting,
|
||||
showHostPicker,
|
||||
setShowHostPicker,
|
||||
hosts,
|
||||
side,
|
||||
hostSearch,
|
||||
setHostSearch,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}) => (
|
||||
<>
|
||||
{/* Dialogs */}
|
||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sftp.folderName")}</Label>
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder={t("sftp.folderName.placeholder")}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNewFolderDialog(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!newFolderName.trim() || isCreating}
|
||||
>
|
||||
{isCreating && (
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showNewFileDialog} onOpenChange={(open) => {
|
||||
setShowNewFileDialog(open);
|
||||
if (!open) {
|
||||
setFileNameError(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sftp.fileName")}</Label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => {
|
||||
setNewFileName(e.target.value);
|
||||
setFileNameError(null);
|
||||
}}
|
||||
placeholder={t("sftp.fileName.placeholder")}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
|
||||
autoFocus
|
||||
/>
|
||||
{fileNameError && (
|
||||
<div className="text-xs text-destructive">{fileNameError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNewFileDialog(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateFile}
|
||||
disabled={!newFileName.trim() || isCreatingFile}
|
||||
>
|
||||
{isCreatingFile && (
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Overwrite Confirmation Dialog */}
|
||||
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowOverwriteConfirm(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOverwriteConfirm}
|
||||
>
|
||||
{t("sftp.overwrite.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sftp.rename.newName")}</Label>
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder={t("sftp.rename.placeholder")}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRename}
|
||||
disabled={!renameName.trim() || isRenaming}
|
||||
>
|
||||
{isRenaming && (
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("common.rename")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.deleteConfirm.desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteTargets.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting && (
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("action.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SftpHostPicker
|
||||
open={showHostPicker}
|
||||
onOpenChange={setShowHostPicker}
|
||||
hosts={hosts}
|
||||
side={side}
|
||||
hostSearch={hostSearch}
|
||||
onHostSearchChange={setHostSearch}
|
||||
onSelectLocal={() => {
|
||||
onDisconnect();
|
||||
onConnect("local");
|
||||
}}
|
||||
onSelectHost={(host) => {
|
||||
onDisconnect();
|
||||
onConnect(host);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
80
components/sftp/SftpPaneEmptyState.tsx
Normal file
80
components/sftp/SftpPaneEmptyState.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import { HardDrive, Monitor, Plus } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { SftpHostPicker } from "./SftpHostPicker";
|
||||
import type { Host } from "../../domain/models";
|
||||
|
||||
interface SftpPaneEmptyStateProps {
|
||||
side: "left" | "right";
|
||||
showEmptyHeader: boolean;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
showHostPicker: boolean;
|
||||
setShowHostPicker: (open: boolean) => void;
|
||||
hostSearch: string;
|
||||
setHostSearch: (value: string) => void;
|
||||
hosts: Host[];
|
||||
onConnect: (hostId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpPaneEmptyState: React.FC<SftpPaneEmptyStateProps> = ({
|
||||
side,
|
||||
showEmptyHeader,
|
||||
t,
|
||||
showHostPicker,
|
||||
setShowHostPicker,
|
||||
hostSearch,
|
||||
setHostSearch,
|
||||
hosts,
|
||||
onConnect,
|
||||
}) => {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
{showEmptyHeader && (
|
||||
<div className="h-12 px-4 border-b border-border/60 flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||
{side === "left" ? <Monitor size={14} /> : <HardDrive size={14} />}
|
||||
<span>
|
||||
{side === "left" ? t("sftp.pane.local") : t("sftp.pane.remote")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setShowHostPicker(true)}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 p-6">
|
||||
<div className="h-14 w-14 rounded-xl bg-secondary/60 text-primary flex items-center justify-center">
|
||||
{side === "left" ? <Monitor size={24} /> : <HardDrive size={24} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">
|
||||
{t("sftp.pane.selectHostToStart")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.pane.chooseFilesystem")}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowHostPicker(true)}>
|
||||
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SftpHostPicker
|
||||
open={showHostPicker}
|
||||
onOpenChange={setShowHostPicker}
|
||||
hosts={hosts}
|
||||
side={side}
|
||||
hostSearch={hostSearch}
|
||||
onHostSearchChange={setHostSearch}
|
||||
onSelectLocal={() => onConnect("local")}
|
||||
onSelectHost={onConnect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
433
components/sftp/SftpPaneFileList.tsx
Normal file
433
components/sftp/SftpPaneFileList.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { ColumnWidths, SortField, SortOrder } from "./utils";
|
||||
import { isNavigableDirectory } from "./index";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { SftpFileRow } from "./index";
|
||||
|
||||
interface SftpPaneFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
side: "left" | "right";
|
||||
columnWidths: ColumnWidths;
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
isDragOverPane: boolean;
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
onRefresh: () => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
getNextUntitledName: (existingNames: string[]) => string;
|
||||
setNewFileName: (value: string) => void;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
// Row rendering
|
||||
dragOverEntry: string | null;
|
||||
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
|
||||
handleRowOpen: (entry: SftpFileEntry) => void;
|
||||
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onDragEnd: () => void;
|
||||
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
handleRowDragLeave: () => void;
|
||||
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
openRenameDialog: (name: string) => void;
|
||||
openDeleteConfirm: (targets: string[]) => void;
|
||||
rowHeight: number;
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
}
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
t,
|
||||
pane,
|
||||
side,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
fileListRef,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
sortedDisplayFiles,
|
||||
isDragOverPane,
|
||||
draggedFiles,
|
||||
onRefresh,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
getNextUntitledName,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
dragOverEntry,
|
||||
handleRowSelect,
|
||||
handleRowOpen,
|
||||
handleFileDragStart,
|
||||
onDragEnd,
|
||||
handleEntryDragOver,
|
||||
handleRowDragLeave,
|
||||
handleEntryDrop,
|
||||
onCopyToOtherPane,
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onEditPermissions,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
rowHeight,
|
||||
visibleRows,
|
||||
}) => {
|
||||
const filesByName = useMemo(() => {
|
||||
const map = new Map<string, SftpFileEntry>();
|
||||
sortedDisplayFiles.forEach((entry) => {
|
||||
map.set(entry.name, entry);
|
||||
});
|
||||
return map;
|
||||
}, [sortedDisplayFiles]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
(entry: SftpFileEntry, index: number) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<SftpFileRow
|
||||
entry={entry}
|
||||
index={index}
|
||||
isSelected={pane.selectedFiles.has(entry.name)}
|
||||
isDragOver={dragOverEntry === entry.name}
|
||||
columnWidths={columnWidths}
|
||||
onSelect={handleRowSelect}
|
||||
onOpen={handleRowOpen}
|
||||
onDragStart={handleFileDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={handleEntryDragOver}
|
||||
onDragLeave={handleRowDragLeave}
|
||||
onDrop={handleEntryDrop}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
{entry.name !== ".." && (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleRowOpen(entry)}>
|
||||
{isNavigableDirectory(entry) ? (
|
||||
<>
|
||||
<Folder size={14} className="mr-2" /> {t("sftp.context.open")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.open")}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
{!isNavigableDirectory(entry) && onOpenFileWith && (
|
||||
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.openWith")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && !isKnownBinaryFile(entry.name) && onEditFile && (
|
||||
<ContextMenuItem onClick={() => onEditFile(entry)}>
|
||||
<Edit2 size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onDownloadFile && (
|
||||
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const files = pane.selectedFiles.has(entry.name)
|
||||
? Array.from(pane.selectedFiles)
|
||||
: [entry.name];
|
||||
const fileData = files.map((name) => {
|
||||
const fileName = String(name);
|
||||
const file = filesByName.get(fileName);
|
||||
return {
|
||||
name: fileName,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
onCopyToOtherPane(fileData);
|
||||
}}
|
||||
>
|
||||
<Copy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyToOtherPane")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
|
||||
<Pencil size={14} className="mr-2" /> {t("common.rename")}
|
||||
</ContextMenuItem>
|
||||
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
|
||||
<ContextMenuItem onClick={() => onEditPermissions(entry)}>
|
||||
<Shield size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.permissions")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
const files = pane.selectedFiles.has(entry.name)
|
||||
? Array.from(pane.selectedFiles)
|
||||
: [entry.name];
|
||||
openDeleteConfirm(files);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onRefresh}>
|
||||
<RefreshCw size={14} className="mr-2" /> {t("common.refresh")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
|
||||
<FolderPlus size={14} className="mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
|
||||
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
),
|
||||
[
|
||||
columnWidths,
|
||||
dragOverEntry,
|
||||
filesByName,
|
||||
handleEntryDragOver,
|
||||
handleEntryDrop,
|
||||
handleFileDragStart,
|
||||
handleRowDragLeave,
|
||||
handleRowOpen,
|
||||
handleRowSelect,
|
||||
onCopyToOtherPane,
|
||||
onDownloadFile,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
onOpenFileWith,
|
||||
onRefresh,
|
||||
openDeleteConfirm,
|
||||
openRenameDialog,
|
||||
pane.connection,
|
||||
pane.selectedFiles,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const fileRows = useMemo(
|
||||
() =>
|
||||
shouldVirtualize
|
||||
? visibleRows.map(({ entry, index, top }) => (
|
||||
<div
|
||||
key={entry.name}
|
||||
className="absolute left-0 right-0 border-b border-border/30"
|
||||
style={{ top, height: rowHeight }}
|
||||
>
|
||||
{renderRow(entry, index)}
|
||||
</div>
|
||||
))
|
||||
: sortedDisplayFiles.map((entry, index) => (
|
||||
<React.Fragment key={entry.name}>
|
||||
{renderRow(entry, index)}
|
||||
</React.Fragment>
|
||||
)),
|
||||
[renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* File list header */}
|
||||
<div
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<span>{t("sftp.columns.name")}</span>
|
||||
{sortField === "name" && (
|
||||
<span className="text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("name", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("modified")}
|
||||
>
|
||||
<span>{t("sftp.columns.modified")}</span>
|
||||
{sortField === "modified" && (
|
||||
<span className="text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("modified", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
|
||||
onClick={() => handleSort("size")}
|
||||
>
|
||||
{sortField === "size" && (
|
||||
<span className="text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{t("sftp.columns.size")}</span>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("size", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground justify-end"
|
||||
onClick={() => handleSort("type")}
|
||||
>
|
||||
{sortField === "type" && (
|
||||
<span className="text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{t("sftp.columns.kind")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list with empty area context menu */}
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={fileListRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
|
||||
)}
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{t(pane.error)}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
shouldVirtualize ? "relative" : "divide-y divide-border/30",
|
||||
)}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{fileRows}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop overlay */}
|
||||
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-primary">
|
||||
<ArrowDown size={32} />
|
||||
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onRefresh}>
|
||||
<RefreshCw size={14} className="mr-2" />{t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
|
||||
<FolderPlus size={14} className="mr-2" />{t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
}}>
|
||||
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
|
||||
<span>
|
||||
{t("sftp.itemsCount", {
|
||||
count: sortedDisplayFiles.filter((f) => f.name !== "..").length,
|
||||
})}
|
||||
{pane.selectedFiles.size > 0 &&
|
||||
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
|
||||
</span>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{pane.connection.currentPath}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
|
||||
{pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 size={32} className="animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
277
components/sftp/SftpPaneToolbar.tsx
Normal file
277
components/sftp/SftpPaneToolbar.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import React from "react";
|
||||
import { ChevronLeft, FilePlus, Folder, FolderPlus, Home, RefreshCw, Search, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { SftpBreadcrumb } from "./index";
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
|
||||
interface SftpPaneToolbarProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
onNavigateUp: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onSetFilter: (value: string) => void;
|
||||
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
|
||||
onRefresh: () => void;
|
||||
showFilterBar: boolean;
|
||||
setShowFilterBar: (open: boolean) => void;
|
||||
filterInputRef: React.RefObject<HTMLInputElement>;
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
setShowPathSuggestions: (open: boolean) => void;
|
||||
showPathSuggestions: boolean;
|
||||
setPathSuggestionIndex: (value: number) => void;
|
||||
pathSuggestions: { path: string; type: "folder" | "history" }[];
|
||||
pathSuggestionIndex: number;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
pathDropdownRef: React.RefObject<HTMLDivElement>;
|
||||
handlePathBlur: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: (pathOverride?: string) => void;
|
||||
startTransition: React.TransitionStartFunction;
|
||||
getNextUntitledName: (existingNames: string[]) => string;
|
||||
setNewFileName: (value: string) => void;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
t,
|
||||
pane,
|
||||
onNavigateUp,
|
||||
onNavigateTo,
|
||||
onSetFilter,
|
||||
onSetFilenameEncoding,
|
||||
onRefresh,
|
||||
showFilterBar,
|
||||
setShowFilterBar,
|
||||
filterInputRef,
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
setShowPathSuggestions,
|
||||
setPathSuggestionIndex,
|
||||
showPathSuggestions,
|
||||
pathSuggestions,
|
||||
pathSuggestionIndex,
|
||||
pathInputRef,
|
||||
pathDropdownRef,
|
||||
handlePathBlur,
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
startTransition,
|
||||
getNextUntitledName,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
}) => (
|
||||
<>
|
||||
{/* Toolbar - always visible when connected */}
|
||||
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onNavigateUp}
|
||||
title={t("sftp.goUp")}
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</Button>
|
||||
|
||||
{/* Editable Breadcrumb with autocomplete */}
|
||||
{isEditingPath ? (
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => {
|
||||
setEditingPathValue(e.target.value);
|
||||
setShowPathSuggestions(true);
|
||||
setPathSuggestionIndex(-1);
|
||||
}}
|
||||
onBlur={handlePathBlur}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
onFocus={() => setShowPathSuggestions(true)}
|
||||
className="h-5 w-full text-[10px] bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
{showPathSuggestions && pathSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={pathDropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
|
||||
>
|
||||
{pathSuggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={suggestion.path}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
|
||||
idx === pathSuggestionIndex && "bg-secondary/80",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePathSubmit(suggestion.path);
|
||||
}}
|
||||
>
|
||||
{suggestion.type === "folder" ? (
|
||||
<Folder size={12} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Home
|
||||
size={12}
|
||||
className="text-muted-foreground shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate font-mono">
|
||||
{suggestion.path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={t("sftp.path.doubleClickToEdit")}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={pane.connection.currentPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir &&
|
||||
onNavigateTo(pane.connection.homeDir)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{!pane.connection?.isLocal && (
|
||||
<Select
|
||||
value={pane.filenameEncoding}
|
||||
onValueChange={(value) => onSetFilenameEncoding(value as SftpFilenameEncoding)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[120px] text-[10px]" title={t("sftp.encoding.label")}>
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setShowNewFolderDialog(true)}
|
||||
title={t("sftp.newFolder")}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
}}
|
||||
title={t("sftp.newFile")}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", pane.filter && "text-primary")}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
if (!showFilterBar) {
|
||||
setTimeout(() => filterInputRef.current?.focus(), 0);
|
||||
}
|
||||
}}
|
||||
title={t("sftp.filter")}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onRefresh}
|
||||
title={t("common.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={
|
||||
pane.loading || pane.reconnecting ? "animate-spin" : ""
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline filter bar - appears below toolbar when search is active */}
|
||||
{showFilterBar && (
|
||||
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
ref={filterInputRef}
|
||||
value={pane.filter}
|
||||
onChange={(e) =>
|
||||
startTransition(() => onSetFilter(e.target.value))
|
||||
}
|
||||
placeholder={t("sftp.filter.placeholder")}
|
||||
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (pane.filter) {
|
||||
startTransition(() => onSetFilter(""));
|
||||
} else {
|
||||
setShowFilterBar(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pane.filter && (
|
||||
<button
|
||||
onClick={() => startTransition(() => onSetFilter(""))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
startTransition(() => onSetFilter(""));
|
||||
setShowFilterBar(false);
|
||||
}}
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
370
components/sftp/SftpPaneView.tsx
Normal file
370
components/sftp/SftpPaneView.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import React, { memo, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { SftpPaneDialogs } from "./SftpPaneDialogs";
|
||||
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
|
||||
import { SftpPaneFileList } from "./SftpPaneFileList";
|
||||
import { SftpPaneToolbar } from "./SftpPaneToolbar";
|
||||
import {
|
||||
useActiveTabId,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpShowHiddenFiles,
|
||||
} from "./index";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||
import { useSftpPanePath } from "./hooks/useSftpPanePath";
|
||||
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
|
||||
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
paneId: string;
|
||||
isFirstPane: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SftpPaneWrapper = memo<SftpPaneWrapperProps>(({ side, paneId, isFirstPane, children }) => {
|
||||
const activeTabId = useActiveTabId(side);
|
||||
const isActive = activeTabId ? paneId === activeTabId : isFirstPane;
|
||||
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
: { visibility: "hidden", pointerEvents: "none" };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute inset-0", isActive ? "z-10" : "z-0")}
|
||||
style={containerStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SftpPaneWrapper.displayName = "SftpPaneWrapper";
|
||||
|
||||
interface SftpPaneViewProps {
|
||||
side: "left" | "right";
|
||||
pane: SftpPane;
|
||||
showHeader?: boolean;
|
||||
showEmptyHeader?: boolean;
|
||||
}
|
||||
|
||||
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
side,
|
||||
pane,
|
||||
showHeader = true,
|
||||
showEmptyHeader = true,
|
||||
}) => {
|
||||
const isActive = true;
|
||||
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
const hosts = useSftpHosts();
|
||||
const showHiddenFiles = useSftpShowHiddenFiles();
|
||||
|
||||
const { t } = useI18n();
|
||||
const [, startTransition] = useTransition();
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useRenderTracker(`SftpPaneView[${side}]`, {
|
||||
side,
|
||||
paneId: pane.id,
|
||||
paneConnected: pane.connected,
|
||||
panePath: pane.currentPath,
|
||||
showHeader,
|
||||
draggedFilesCount: draggedFiles?.length ?? 0,
|
||||
});
|
||||
|
||||
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } = useSftpPaneSorting();
|
||||
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
|
||||
files: pane.files,
|
||||
filter: pane.filter,
|
||||
connection: pane.connection,
|
||||
showHiddenFiles,
|
||||
sortField,
|
||||
sortOrder,
|
||||
});
|
||||
const {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
showPathSuggestions,
|
||||
pathSuggestionIndex,
|
||||
pathInputRef,
|
||||
pathDropdownRef,
|
||||
pathSuggestions,
|
||||
setEditingPathValue,
|
||||
setShowPathSuggestions,
|
||||
setPathSuggestionIndex,
|
||||
handlePathBlur,
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
} = useSftpPanePath({
|
||||
connection: pane.connection,
|
||||
filteredFiles,
|
||||
onNavigateTo: callbacks.onNavigateTo,
|
||||
});
|
||||
const {
|
||||
showHostPicker,
|
||||
hostSearch,
|
||||
showNewFolderDialog,
|
||||
newFolderName,
|
||||
showNewFileDialog,
|
||||
newFileName,
|
||||
fileNameError,
|
||||
showOverwriteConfirm,
|
||||
overwriteTarget,
|
||||
showRenameDialog,
|
||||
renameTarget: _renameTarget,
|
||||
renameName,
|
||||
showDeleteConfirm,
|
||||
deleteTargets,
|
||||
isCreating,
|
||||
isCreatingFile,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
setShowHostPicker,
|
||||
setHostSearch,
|
||||
setShowNewFolderDialog,
|
||||
setNewFolderName,
|
||||
setShowNewFileDialog,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
setShowOverwriteConfirm,
|
||||
setShowRenameDialog,
|
||||
setRenameName,
|
||||
setShowDeleteConfirm,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
handleConfirmOverwrite,
|
||||
handleRename,
|
||||
handleDelete,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
getNextUntitledName,
|
||||
} = useSftpPaneDialogs({
|
||||
t,
|
||||
pane,
|
||||
onCreateDirectory: callbacks.onCreateDirectory,
|
||||
onCreateFile: callbacks.onCreateFile,
|
||||
onRenameFile: callbacks.onRenameFile,
|
||||
onDeleteFiles: callbacks.onDeleteFiles,
|
||||
onClearSelection: callbacks.onClearSelection,
|
||||
});
|
||||
const {
|
||||
dragOverEntry,
|
||||
isDragOverPane,
|
||||
paneContainerRef,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
handlePaneDrop,
|
||||
handleFileDragStart,
|
||||
handleEntryDragOver,
|
||||
handleEntryDrop,
|
||||
handleRowDragLeave,
|
||||
handleRowSelect,
|
||||
handleRowOpen,
|
||||
} = useSftpPaneDragAndSelect({
|
||||
side,
|
||||
pane,
|
||||
sortedDisplayFiles,
|
||||
draggedFiles,
|
||||
onDragStart,
|
||||
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
|
||||
onUploadExternalFiles: callbacks.onUploadExternalFiles,
|
||||
onOpenEntry: callbacks.onOpenEntry,
|
||||
onRangeSelect: callbacks.onRangeSelect,
|
||||
onToggleSelection: callbacks.onToggleSelection,
|
||||
});
|
||||
const {
|
||||
fileListRef,
|
||||
rowHeight,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
} = useSftpPaneVirtualList({
|
||||
isActive,
|
||||
sortedDisplayFiles,
|
||||
});
|
||||
|
||||
const handleSortWithTransition = (field: typeof sortField) => {
|
||||
startTransition(() => handleSort(field));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug("SftpPaneView active state", {
|
||||
side,
|
||||
paneId: pane.id,
|
||||
isActive,
|
||||
});
|
||||
}, [isActive, pane.id, side]);
|
||||
|
||||
if (!pane.connection) {
|
||||
return (
|
||||
<SftpPaneEmptyState
|
||||
side={side}
|
||||
showEmptyHeader={showEmptyHeader}
|
||||
t={t}
|
||||
showHostPicker={showHostPicker}
|
||||
setShowHostPicker={setShowHostPicker}
|
||||
hostSearch={hostSearch}
|
||||
setHostSearch={setHostSearch}
|
||||
hosts={hosts}
|
||||
onConnect={callbacks.onConnect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={paneContainerRef}
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col transition-colors",
|
||||
isDragOverPane && "bg-primary/5",
|
||||
)}
|
||||
onDragOver={handlePaneDragOver}
|
||||
onDragLeave={handlePaneDragLeave}
|
||||
onDrop={handlePaneDrop}
|
||||
>
|
||||
<SftpPaneToolbar
|
||||
t={t}
|
||||
pane={pane}
|
||||
onNavigateUp={callbacks.onNavigateUp}
|
||||
onNavigateTo={callbacks.onNavigateTo}
|
||||
onSetFilter={callbacks.onSetFilter}
|
||||
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
|
||||
onRefresh={callbacks.onRefresh}
|
||||
showFilterBar={showFilterBar}
|
||||
setShowFilterBar={setShowFilterBar}
|
||||
filterInputRef={filterInputRef}
|
||||
isEditingPath={isEditingPath}
|
||||
editingPathValue={editingPathValue}
|
||||
setEditingPathValue={setEditingPathValue}
|
||||
setShowPathSuggestions={setShowPathSuggestions}
|
||||
showPathSuggestions={showPathSuggestions}
|
||||
setPathSuggestionIndex={setPathSuggestionIndex}
|
||||
pathSuggestions={pathSuggestions}
|
||||
pathSuggestionIndex={pathSuggestionIndex}
|
||||
pathInputRef={pathInputRef}
|
||||
pathDropdownRef={pathDropdownRef}
|
||||
handlePathBlur={handlePathBlur}
|
||||
handlePathKeyDown={handlePathKeyDown}
|
||||
handlePathDoubleClick={handlePathDoubleClick}
|
||||
handlePathSubmit={handlePathSubmit}
|
||||
startTransition={startTransition}
|
||||
getNextUntitledName={getNextUntitledName}
|
||||
setNewFileName={setNewFileName}
|
||||
setFileNameError={setFileNameError}
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
/>
|
||||
|
||||
<SftpPaneFileList
|
||||
t={t}
|
||||
pane={pane}
|
||||
side={side}
|
||||
columnWidths={columnWidths}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
handleSort={handleSortWithTransition}
|
||||
handleResizeStart={handleResizeStart}
|
||||
fileListRef={fileListRef}
|
||||
handleFileListScroll={handleFileListScroll}
|
||||
shouldVirtualize={shouldVirtualize}
|
||||
totalHeight={totalHeight}
|
||||
sortedDisplayFiles={sortedDisplayFiles}
|
||||
isDragOverPane={isDragOverPane}
|
||||
draggedFiles={draggedFiles}
|
||||
onRefresh={callbacks.onRefresh}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
getNextUntitledName={getNextUntitledName}
|
||||
setNewFileName={setNewFileName}
|
||||
setFileNameError={setFileNameError}
|
||||
dragOverEntry={dragOverEntry}
|
||||
handleRowSelect={handleRowSelect}
|
||||
handleRowOpen={handleRowOpen}
|
||||
handleFileDragStart={handleFileDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
handleEntryDragOver={handleEntryDragOver}
|
||||
handleRowDragLeave={handleRowDragLeave}
|
||||
handleEntryDrop={handleEntryDrop}
|
||||
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
openRenameDialog={openRenameDialog}
|
||||
openDeleteConfirm={openDeleteConfirm}
|
||||
rowHeight={rowHeight}
|
||||
visibleRows={visibleRows}
|
||||
/>
|
||||
|
||||
<SftpPaneDialogs
|
||||
t={t}
|
||||
showNewFolderDialog={showNewFolderDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
newFolderName={newFolderName}
|
||||
setNewFolderName={setNewFolderName}
|
||||
handleCreateFolder={handleCreateFolder}
|
||||
isCreating={isCreating}
|
||||
showNewFileDialog={showNewFileDialog}
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
newFileName={newFileName}
|
||||
setNewFileName={setNewFileName}
|
||||
fileNameError={fileNameError}
|
||||
setFileNameError={setFileNameError}
|
||||
handleCreateFile={handleCreateFile}
|
||||
isCreatingFile={isCreatingFile}
|
||||
showOverwriteConfirm={showOverwriteConfirm}
|
||||
setShowOverwriteConfirm={setShowOverwriteConfirm}
|
||||
overwriteTarget={overwriteTarget}
|
||||
handleOverwriteConfirm={handleConfirmOverwrite}
|
||||
showRenameDialog={showRenameDialog}
|
||||
setShowRenameDialog={setShowRenameDialog}
|
||||
renameName={renameName}
|
||||
setRenameName={setRenameName}
|
||||
handleRename={handleRename}
|
||||
isRenaming={isRenaming}
|
||||
showDeleteConfirm={showDeleteConfirm}
|
||||
setShowDeleteConfirm={setShowDeleteConfirm}
|
||||
deleteTargets={deleteTargets}
|
||||
handleDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
showHostPicker={showHostPicker}
|
||||
setShowHostPicker={setShowHostPicker}
|
||||
hosts={hosts}
|
||||
side={side}
|
||||
hostSearch={hostSearch}
|
||||
setHostSearch={setHostSearch}
|
||||
onConnect={callbacks.onConnect}
|
||||
onDisconnect={callbacks.onDisconnect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const sftpPaneViewAreEqual = (
|
||||
prev: SftpPaneViewProps,
|
||||
next: SftpPaneViewProps,
|
||||
): boolean => {
|
||||
if (prev.pane !== next.pane) return false;
|
||||
if (prev.side !== next.side) return false;
|
||||
if (prev.showHeader !== next.showHeader) return false;
|
||||
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const SftpPaneView = memo(SftpPaneViewInner, sftpPaneViewAreEqual);
|
||||
SftpPaneView.displayName = "SftpPaneView";
|
||||
|
||||
export { SftpPaneView, SftpPaneWrapper };
|
||||
@@ -5,12 +5,13 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
CheckCircle2,
|
||||
FolderUp,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React,{ memo } from 'react';
|
||||
import React,{ memo, useRef, useEffect } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -26,11 +27,49 @@ interface SftpTransferItemProps {
|
||||
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel, onRetry, onDismiss }) => {
|
||||
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
|
||||
|
||||
const speedFormatted = formatSpeed(task.speed);
|
||||
// Use refs to store stable display values and prevent flickering
|
||||
const lastSpeedRef = useRef<number>(0);
|
||||
const lastSpeedTimeRef = useRef<number>(Date.now());
|
||||
const displaySpeedRef = useRef<string>('');
|
||||
|
||||
// Update speed display with smoothing - only update if speed is stable for a moment
|
||||
useEffect(() => {
|
||||
if (task.status !== 'transferring') {
|
||||
displaySpeedRef.current = '';
|
||||
lastSpeedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastUpdate = now - lastSpeedTimeRef.current;
|
||||
|
||||
// Only update speed display if:
|
||||
// 1. Speed is above threshold (100 bytes/s)
|
||||
// 2. Either it's been at least 500ms since last update, or speed changed significantly (>50%)
|
||||
if (task.speed > 100) {
|
||||
const speedChange = Math.abs(task.speed - lastSpeedRef.current);
|
||||
const significantChange = lastSpeedRef.current > 0 && speedChange / lastSpeedRef.current > 0.5;
|
||||
|
||||
if (timeSinceLastUpdate >= 500 || significantChange || lastSpeedRef.current === 0) {
|
||||
lastSpeedRef.current = task.speed;
|
||||
lastSpeedTimeRef.current = now;
|
||||
displaySpeedRef.current = formatSpeed(task.speed);
|
||||
}
|
||||
} else if (task.speed === 0 && lastSpeedRef.current > 0) {
|
||||
// Don't immediately clear speed when it drops to 0
|
||||
// Keep showing last speed for a short period
|
||||
if (timeSinceLastUpdate >= 1000) {
|
||||
lastSpeedRef.current = 0;
|
||||
displaySpeedRef.current = '';
|
||||
}
|
||||
}
|
||||
}, [task.speed, task.status]);
|
||||
|
||||
// Calculate remaining time based on stable speed
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const remainingTime = task.speed > 0
|
||||
? Math.ceil(remainingBytes / task.speed)
|
||||
const stableSpeed = lastSpeedRef.current > 0 ? lastSpeedRef.current : task.speed;
|
||||
const remainingTime = stableSpeed > 0
|
||||
? Math.ceil(remainingBytes / stableSpeed)
|
||||
: 0;
|
||||
const remainingFormatted = remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
@@ -45,11 +84,17 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
? formatTransferBytes(task.totalBytes)
|
||||
: '';
|
||||
|
||||
// Use the stable display speed
|
||||
const speedFormatted = displaySpeedRef.current;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center shrink-0">
|
||||
{task.status === 'transferring' && <Loader2 size={14} className="animate-spin text-primary" />}
|
||||
{task.status === 'pending' && <ArrowDown size={14} className="text-muted-foreground animate-bounce" />}
|
||||
{task.status === 'pending' && (task.isDirectory
|
||||
? <FolderUp size={14} className="text-muted-foreground animate-pulse" />
|
||||
: <ArrowDown size={14} className="text-muted-foreground animate-bounce" />
|
||||
)}
|
||||
{task.status === 'completed' && <CheckCircle2 size={14} className="text-green-500" />}
|
||||
{task.status === 'failed' && <XCircle size={14} className="text-destructive" />}
|
||||
{task.status === 'cancelled' && <XCircle size={14} className="text-muted-foreground" />}
|
||||
@@ -59,10 +104,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm truncate font-medium">{task.fileName}</span>
|
||||
{task.status === 'transferring' && speedFormatted && (
|
||||
<span className="text-xs text-primary/80 font-mono">{speedFormatted}</span>
|
||||
<span className="text-xs text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
|
||||
)}
|
||||
{task.status === 'transferring' && remainingFormatted && (
|
||||
<span className="text-xs text-muted-foreground">{remainingFormatted}</span>
|
||||
<span className="text-xs text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === 'transferring' || task.status === 'pending') && (
|
||||
@@ -133,5 +178,44 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpTransferItem = memo(SftpTransferItemInner);
|
||||
// Custom comparison function to reduce unnecessary re-renders
|
||||
// Only re-render if meaningful values change
|
||||
const arePropsEqual = (
|
||||
prevProps: SftpTransferItemProps,
|
||||
nextProps: SftpTransferItemProps
|
||||
): boolean => {
|
||||
const prev = prevProps.task;
|
||||
const next = nextProps.task;
|
||||
|
||||
// Always re-render on status change
|
||||
if (prev.status !== next.status) return false;
|
||||
|
||||
// Always re-render on error change
|
||||
if (prev.error !== next.error) return false;
|
||||
|
||||
// Always re-render on fileName change
|
||||
if (prev.fileName !== next.fileName) return false;
|
||||
|
||||
// For transferring status, throttle updates based on progress
|
||||
if (next.status === 'transferring') {
|
||||
// Re-render if progress changed by more than 0.5%
|
||||
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
|
||||
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
|
||||
if (Math.abs(nextProgress - prevProgress) >= 0.5) return false;
|
||||
|
||||
// Re-render periodically for speed updates (every ~500ms based on speed changes)
|
||||
// The component uses refs to smooth speed display, so we allow more frequent renders
|
||||
const speedDiff = Math.abs(next.speed - prev.speed);
|
||||
if (speedDiff > 1000) return false; // Re-render if speed changed by more than 1KB/s
|
||||
}
|
||||
|
||||
// For pending status, don't re-render unless status changes
|
||||
if (next.status === 'pending') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const SftpTransferItem = memo(SftpTransferItemInner, arePropsEqual);
|
||||
SftpTransferItem.displayName = 'SftpTransferItem';
|
||||
|
||||
283
components/sftp/hooks/useSftpPaneDialogs.ts
Normal file
283
components/sftp/hooks/useSftpPaneDialogs.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { SftpPaneCallbacks } from "../SftpContext";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
|
||||
interface UseSftpPaneDialogsParams {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
|
||||
onCreateFile: SftpPaneCallbacks["onCreateFile"];
|
||||
onRenameFile: SftpPaneCallbacks["onRenameFile"];
|
||||
onDeleteFiles: SftpPaneCallbacks["onDeleteFiles"];
|
||||
onClearSelection: SftpPaneCallbacks["onClearSelection"];
|
||||
}
|
||||
|
||||
interface UseSftpPaneDialogsResult {
|
||||
showHostPicker: boolean;
|
||||
hostSearch: string;
|
||||
showNewFolderDialog: boolean;
|
||||
newFolderName: string;
|
||||
showNewFileDialog: boolean;
|
||||
newFileName: string;
|
||||
fileNameError: string | null;
|
||||
showOverwriteConfirm: boolean;
|
||||
overwriteTarget: string | null;
|
||||
showRenameDialog: boolean;
|
||||
renameTarget: string | null;
|
||||
renameName: string;
|
||||
showDeleteConfirm: boolean;
|
||||
deleteTargets: string[];
|
||||
isCreating: boolean;
|
||||
isCreatingFile: boolean;
|
||||
isRenaming: boolean;
|
||||
isDeleting: boolean;
|
||||
setShowHostPicker: (open: boolean) => void;
|
||||
setHostSearch: (value: string) => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setNewFolderName: (value: string) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
setNewFileName: (value: string) => void;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
setShowOverwriteConfirm: (open: boolean) => void;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
setRenameName: (value: string) => void;
|
||||
setShowDeleteConfirm: (open: boolean) => void;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: (forceOverwrite?: boolean) => Promise<void>;
|
||||
handleConfirmOverwrite: () => Promise<void>;
|
||||
handleRename: () => Promise<void>;
|
||||
handleDelete: () => Promise<void>;
|
||||
openRenameDialog: (name: string) => void;
|
||||
openDeleteConfirm: (names: string[]) => void;
|
||||
getNextUntitledName: (existingFiles: string[]) => string;
|
||||
}
|
||||
|
||||
export const useSftpPaneDialogs = ({
|
||||
t,
|
||||
pane,
|
||||
onCreateDirectory,
|
||||
onCreateFile,
|
||||
onRenameFile,
|
||||
onDeleteFiles,
|
||||
onClearSelection,
|
||||
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
|
||||
const [showHostPicker, setShowHostPicker] = useState(false);
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [fileNameError, setFileNameError] = useState<string | null>(null);
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
|
||||
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const validateFileName = useCallback(
|
||||
(name: string): string | null => {
|
||||
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
|
||||
const RESERVED_NAMES = new Set([
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
|
||||
if (invalidMatch) {
|
||||
return t("sftp.error.invalidFileName", { chars: invalidMatch[0] });
|
||||
}
|
||||
|
||||
const baseName = trimmed.split(".")[0].toUpperCase();
|
||||
if (RESERVED_NAMES.has(baseName)) {
|
||||
return t("sftp.error.reservedName");
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
|
||||
const existingSet = new Set(existingFiles.map((f) => f.toLowerCase()));
|
||||
|
||||
if (!existingSet.has("untitled.txt")) {
|
||||
return "untitled.txt";
|
||||
}
|
||||
|
||||
let counter = 1;
|
||||
while (counter < 1000) {
|
||||
const name = `untitled (${counter}).txt`;
|
||||
if (!existingSet.has(name.toLowerCase())) {
|
||||
return name;
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
return `untitled_${Date.now()}.txt`;
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim() || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreateDirectory(newFolderName.trim());
|
||||
setShowNewFolderDialog(false);
|
||||
setNewFolderName("");
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFile = async (forceOverwrite = false) => {
|
||||
const trimmedName = newFileName.trim();
|
||||
if (!trimmedName || isCreatingFile) return;
|
||||
|
||||
const error = validateFileName(trimmedName);
|
||||
if (error) {
|
||||
setFileNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forceOverwrite) {
|
||||
const existingFile = pane.files.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
|
||||
);
|
||||
if (existingFile) {
|
||||
setOverwriteTarget(trimmedName);
|
||||
setShowOverwriteConfirm(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsCreatingFile(true);
|
||||
try {
|
||||
await onCreateFile(trimmedName);
|
||||
setShowNewFileDialog(false);
|
||||
setShowOverwriteConfirm(false);
|
||||
setOverwriteTarget(null);
|
||||
setNewFileName("");
|
||||
setFileNameError(null);
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} finally {
|
||||
setIsCreatingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmOverwrite = async () => {
|
||||
await handleCreateFile(true);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renameTarget || !renameName.trim() || isRenaming) return;
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
await onRenameFile(renameTarget, renameName.trim());
|
||||
setShowRenameDialog(false);
|
||||
setRenameTarget(null);
|
||||
setRenameName("");
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteTargets.length === 0 || isDeleting) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDeleteFiles(deleteTargets);
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteTargets([]);
|
||||
onClearSelection();
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openRenameDialog = useCallback((name: string) => {
|
||||
setRenameTarget(name);
|
||||
setRenameName(name);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const openDeleteConfirm = useCallback((names: string[]) => {
|
||||
setDeleteTargets(names);
|
||||
setShowDeleteConfirm(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showHostPicker,
|
||||
hostSearch,
|
||||
showNewFolderDialog,
|
||||
newFolderName,
|
||||
showNewFileDialog,
|
||||
newFileName,
|
||||
fileNameError,
|
||||
showOverwriteConfirm,
|
||||
overwriteTarget,
|
||||
showRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
showDeleteConfirm,
|
||||
deleteTargets,
|
||||
isCreating,
|
||||
isCreatingFile,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
setShowHostPicker,
|
||||
setHostSearch,
|
||||
setShowNewFolderDialog,
|
||||
setNewFolderName,
|
||||
setShowNewFileDialog,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
setShowOverwriteConfirm,
|
||||
setShowRenameDialog,
|
||||
setRenameName,
|
||||
setShowDeleteConfirm,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
handleConfirmOverwrite,
|
||||
handleRename,
|
||||
handleDelete,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
getNextUntitledName,
|
||||
};
|
||||
};
|
||||
206
components/sftp/hooks/useSftpPaneDragAndSelect.ts
Normal file
206
components/sftp/hooks/useSftpPaneDragAndSelect.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPaneCallbacks, SftpDragCallbacks } from "../SftpContext";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
|
||||
interface UseSftpPaneDragAndSelectParams {
|
||||
side: "left" | "right";
|
||||
pane: { selectedFiles: Set<string> };
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
onDragStart: SftpDragCallbacks["onDragStart"];
|
||||
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
|
||||
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
|
||||
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
|
||||
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
|
||||
onToggleSelection: SftpPaneCallbacks["onToggleSelection"];
|
||||
}
|
||||
|
||||
interface UseSftpPaneDragAndSelectResult {
|
||||
dragOverEntry: string | null;
|
||||
isDragOverPane: boolean;
|
||||
paneContainerRef: React.RefObject<HTMLDivElement>;
|
||||
handlePaneDragOver: (e: React.DragEvent) => void;
|
||||
handlePaneDragLeave: (e: React.DragEvent) => void;
|
||||
handlePaneDrop: (e: React.DragEvent) => Promise<void>;
|
||||
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
handleRowDragLeave: () => void;
|
||||
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
|
||||
handleRowOpen: (entry: SftpFileEntry) => void;
|
||||
}
|
||||
|
||||
export const useSftpPaneDragAndSelect = ({
|
||||
side,
|
||||
pane,
|
||||
sortedDisplayFiles,
|
||||
draggedFiles,
|
||||
onDragStart,
|
||||
onReceiveFromOtherPane,
|
||||
onUploadExternalFiles,
|
||||
onOpenEntry,
|
||||
onRangeSelect,
|
||||
onToggleSelection,
|
||||
}: UseSftpPaneDragAndSelectParams): UseSftpPaneDragAndSelectResult => {
|
||||
const [dragOverEntry, setDragOverEntry] = useState<string | null>(null);
|
||||
const [isDragOverPane, setIsDragOverPane] = useState(false);
|
||||
const paneContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const selectedFilesRef = useRef(pane.selectedFiles);
|
||||
const sortedFilesRef = useRef(sortedDisplayFiles);
|
||||
|
||||
useEffect(() => {
|
||||
selectedFilesRef.current = pane.selectedFiles;
|
||||
}, [pane.selectedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
sortedFilesRef.current = sortedDisplayFiles;
|
||||
}, [sortedDisplayFiles]);
|
||||
|
||||
const handlePaneDragOver = (e: React.DragEvent) => {
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
|
||||
if (hasFiles) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverPane(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverPane(true);
|
||||
};
|
||||
|
||||
const handlePaneDragLeave = (e: React.DragEvent) => {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget)) return;
|
||||
setIsDragOverPane(false);
|
||||
setDragOverEntry(null);
|
||||
};
|
||||
|
||||
const handlePaneDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOverPane(false);
|
||||
setDragOverEntry(null);
|
||||
|
||||
if (draggedFiles && draggedFiles.length > 0) {
|
||||
if (draggedFiles[0]?.side !== side) {
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
|
||||
await onUploadExternalFiles(e.dataTransfer);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDragStart = useCallback(
|
||||
(entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (entry.name === "..") {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const selectedNames = Array.from(selectedFilesRef.current);
|
||||
const files = selectedNames.includes(entry.name)
|
||||
? sortedFilesRef.current
|
||||
.filter((f) => selectedNames.includes(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
isDirectory: isNavigableDirectory(f),
|
||||
side,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
name: entry.name,
|
||||
isDirectory: isNavigableDirectory(entry),
|
||||
side,
|
||||
},
|
||||
];
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
|
||||
onDragStart(files, side);
|
||||
},
|
||||
[onDragStart, side],
|
||||
);
|
||||
|
||||
const handleEntryDragOver = useCallback(
|
||||
(entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(entry.name);
|
||||
}
|
||||
},
|
||||
[draggedFiles, side],
|
||||
);
|
||||
|
||||
const handleEntryDrop = useCallback(
|
||||
(entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(null);
|
||||
setIsDragOverPane(false);
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
}
|
||||
},
|
||||
[draggedFiles, onReceiveFromOtherPane, side],
|
||||
);
|
||||
|
||||
const handleRowSelect = useCallback(
|
||||
(entry: SftpFileEntry, index: number, e: React.MouseEvent) => {
|
||||
if (entry.name === "..") return;
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const selectedFileNames = sortedDisplayFiles
|
||||
.slice(start, end + 1)
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
onRangeSelect(selectedFileNames);
|
||||
} else {
|
||||
onToggleSelection(entry.name, e.ctrlKey || e.metaKey);
|
||||
lastSelectedIndexRef.current = index;
|
||||
}
|
||||
},
|
||||
[onRangeSelect, onToggleSelection, sortedDisplayFiles],
|
||||
);
|
||||
|
||||
const handleRowOpen = useCallback(
|
||||
(entry: SftpFileEntry) => {
|
||||
onOpenEntry(entry);
|
||||
},
|
||||
[onOpenEntry],
|
||||
);
|
||||
|
||||
const handleRowDragLeave = useCallback(() => {
|
||||
setDragOverEntry(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dragOverEntry,
|
||||
isDragOverPane,
|
||||
paneContainerRef,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
handlePaneDrop,
|
||||
handleFileDragStart,
|
||||
handleEntryDragOver,
|
||||
handleEntryDrop,
|
||||
handleRowDragLeave,
|
||||
handleRowSelect,
|
||||
handleRowOpen,
|
||||
};
|
||||
};
|
||||
99
components/sftp/hooks/useSftpPaneFiles.ts
Normal file
99
components/sftp/hooks/useSftpPaneFiles.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import type { SortField, SortOrder } from "../utils";
|
||||
import { filterHiddenFiles } from "../index";
|
||||
|
||||
interface UseSftpPaneFilesParams {
|
||||
files: SftpFileEntry[];
|
||||
filter: string;
|
||||
connection: SftpPane["connection"] | null;
|
||||
showHiddenFiles: boolean;
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
||||
interface UseSftpPaneFilesResult {
|
||||
filteredFiles: SftpFileEntry[];
|
||||
displayFiles: SftpFileEntry[];
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
}
|
||||
|
||||
export const useSftpPaneFiles = ({
|
||||
files,
|
||||
filter,
|
||||
connection,
|
||||
showHiddenFiles,
|
||||
sortField,
|
||||
sortOrder,
|
||||
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
|
||||
const filteredFiles = useMemo(() => {
|
||||
const term = filter.trim().toLowerCase();
|
||||
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
|
||||
if (!term) return nextFiles;
|
||||
return nextFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, [files, filter, showHiddenFiles]);
|
||||
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!connection) return [];
|
||||
const isRootPath =
|
||||
connection.currentPath === "/" ||
|
||||
/^[A-Za-z]:[\\/]?$/.test(connection.currentPath);
|
||||
if (isRootPath) return filteredFiles;
|
||||
const parentEntry: SftpFileEntry = {
|
||||
name: "..",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: 0,
|
||||
lastModifiedFormatted: "--",
|
||||
};
|
||||
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")] ;
|
||||
}, [connection, filteredFiles]);
|
||||
|
||||
const sortedDisplayFiles = useMemo(() => {
|
||||
if (!displayFiles.length) return displayFiles;
|
||||
|
||||
const parentEntry = displayFiles.find((f) => f.name === "..");
|
||||
const otherFiles = displayFiles.filter((f) => f.name !== "..");
|
||||
|
||||
const sorted = [...otherFiles].sort((a, b) => {
|
||||
if (sortField !== "type") {
|
||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||
}
|
||||
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case "name":
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case "size":
|
||||
cmp = (a.size || 0) - (b.size || 0);
|
||||
break;
|
||||
case "modified":
|
||||
cmp = (a.lastModified || 0) - (b.lastModified || 0);
|
||||
break;
|
||||
case "type": {
|
||||
const extA =
|
||||
a.type === "directory"
|
||||
? "folder"
|
||||
: a.name.split(".").pop()?.toLowerCase() || "";
|
||||
const extB =
|
||||
b.type === "directory"
|
||||
? "folder"
|
||||
: b.name.split(".").pop()?.toLowerCase() || "";
|
||||
cmp = extA.localeCompare(extB);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sortOrder === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
}, [displayFiles, sortField, sortOrder]);
|
||||
|
||||
return { filteredFiles, displayFiles, sortedDisplayFiles };
|
||||
};
|
||||
160
components/sftp/hooks/useSftpPanePath.ts
Normal file
160
components/sftp/hooks/useSftpPanePath.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
|
||||
interface UseSftpPanePathParams {
|
||||
connection: SftpPane["connection"] | null;
|
||||
filteredFiles: SftpFileEntry[];
|
||||
onNavigateTo: (path: string) => void;
|
||||
}
|
||||
|
||||
interface UseSftpPanePathResult {
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
showPathSuggestions: boolean;
|
||||
pathSuggestionIndex: number;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
pathDropdownRef: React.RefObject<HTMLDivElement>;
|
||||
pathSuggestions: { path: string; type: "folder" | "history" }[];
|
||||
setEditingPathValue: (value: string) => void;
|
||||
setShowPathSuggestions: (value: boolean) => void;
|
||||
setPathSuggestionIndex: (value: number) => void;
|
||||
handlePathBlur: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: (pathOverride?: string) => void;
|
||||
}
|
||||
|
||||
export const useSftpPanePath = ({
|
||||
connection,
|
||||
filteredFiles,
|
||||
onNavigateTo,
|
||||
}: UseSftpPanePathParams): UseSftpPanePathResult => {
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editingPathValue, setEditingPathValue] = useState("");
|
||||
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
|
||||
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
const pathDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const pathSuggestions = useMemo(() => {
|
||||
if (!isEditingPath || !connection) return [];
|
||||
const currentValue = editingPathValue.trim().toLowerCase();
|
||||
const suggestions: { path: string; type: "folder" | "history" }[] = [];
|
||||
|
||||
const folders = filteredFiles.filter(
|
||||
(f) => isNavigableDirectory(f) && f.name !== "..",
|
||||
);
|
||||
folders.forEach((f) => {
|
||||
const fullPath =
|
||||
connection.currentPath === "/"
|
||||
? `/${f.name}`
|
||||
: `${connection.currentPath}/${f.name}`;
|
||||
if (
|
||||
!currentValue ||
|
||||
fullPath.toLowerCase().includes(currentValue) ||
|
||||
f.name.toLowerCase().includes(currentValue)
|
||||
) {
|
||||
suggestions.push({ path: fullPath, type: "folder" });
|
||||
}
|
||||
});
|
||||
|
||||
const quickPaths = ["/home", "/var", "/etc", "/tmp", "/usr", "/opt", "/root"];
|
||||
quickPaths.forEach((qp) => {
|
||||
if (!currentValue || qp.toLowerCase().includes(currentValue)) {
|
||||
if (!suggestions.some((s) => s.path === qp)) {
|
||||
suggestions.push({ path: qp, type: "history" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return suggestions.slice(0, 8);
|
||||
}, [connection, editingPathValue, filteredFiles, isEditingPath]);
|
||||
|
||||
const handlePathDoubleClick = () => {
|
||||
if (!connection) return;
|
||||
setEditingPathValue(connection.currentPath);
|
||||
setIsEditingPath(true);
|
||||
setShowPathSuggestions(true);
|
||||
setPathSuggestionIndex(-1);
|
||||
setTimeout(() => pathInputRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const handlePathSubmit = useCallback((pathOverride?: string) => {
|
||||
const newPath = (pathOverride ?? editingPathValue).trim() || "/";
|
||||
setIsEditingPath(false);
|
||||
setShowPathSuggestions(false);
|
||||
setPathSuggestionIndex(-1);
|
||||
if (connection && newPath !== connection.currentPath) {
|
||||
const isWindowsPath = /^[A-Za-z]:/.test(newPath);
|
||||
if (isWindowsPath) {
|
||||
let normalizedPath = newPath;
|
||||
if (/^[A-Za-z]:[\\/]?$/.test(newPath)) {
|
||||
normalizedPath = newPath.charAt(0).toUpperCase() + ":\\";
|
||||
}
|
||||
onNavigateTo(normalizedPath);
|
||||
} else {
|
||||
onNavigateTo(newPath.startsWith("/") ? newPath : `/${newPath}`);
|
||||
}
|
||||
}
|
||||
}, [connection, editingPathValue, onNavigateTo]);
|
||||
|
||||
const handlePathKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showPathSuggestions && pathSuggestions.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setPathSuggestionIndex((prev) =>
|
||||
prev < pathSuggestions.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
return;
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setPathSuggestionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : pathSuggestions.length - 1,
|
||||
);
|
||||
return;
|
||||
} else if (e.key === "Tab" && pathSuggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
setEditingPathValue(pathSuggestions[pathSuggestionIndex].path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
if (pathSuggestionIndex >= 0 && pathSuggestions[pathSuggestionIndex]) {
|
||||
handlePathSubmit(pathSuggestions[pathSuggestionIndex].path);
|
||||
} else {
|
||||
handlePathSubmit();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setIsEditingPath(false);
|
||||
setShowPathSuggestions(false);
|
||||
setPathSuggestionIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
if (!pathDropdownRef.current?.contains(document.activeElement)) {
|
||||
handlePathSubmit();
|
||||
}
|
||||
}, 150);
|
||||
}, [handlePathSubmit]);
|
||||
|
||||
return {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
showPathSuggestions,
|
||||
pathSuggestionIndex,
|
||||
pathInputRef,
|
||||
pathDropdownRef,
|
||||
pathSuggestions,
|
||||
setEditingPathValue,
|
||||
setShowPathSuggestions,
|
||||
setPathSuggestionIndex,
|
||||
handlePathBlur,
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
};
|
||||
};
|
||||
78
components/sftp/hooks/useSftpPaneSorting.ts
Normal file
78
components/sftp/hooks/useSftpPaneSorting.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import type { ColumnWidths, SortField, SortOrder } from "../utils";
|
||||
|
||||
interface UseSftpPaneSortingResult {
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
columnWidths: ColumnWidths;
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
|
||||
name: 45,
|
||||
modified: 25,
|
||||
size: 15,
|
||||
type: 15,
|
||||
});
|
||||
|
||||
const resizingRef = useRef<{
|
||||
field: keyof ColumnWidths;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const diff = e.clientX - resizingRef.current.startX;
|
||||
const newWidth = Math.max(
|
||||
10,
|
||||
Math.min(60, resizingRef.current.startWidth + diff / 5),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizingRef.current!.field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (
|
||||
field: keyof ColumnWidths,
|
||||
e: React.MouseEvent,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
startWidth: columnWidths[field],
|
||||
};
|
||||
document.addEventListener("mousemove", handleResizeMove);
|
||||
document.addEventListener("mouseup", handleResizeEnd);
|
||||
};
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
columnWidths,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
};
|
||||
};
|
||||
126
components/sftp/hooks/useSftpPaneVirtualList.ts
Normal file
126
components/sftp/hooks/useSftpPaneVirtualList.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
|
||||
interface UseSftpPaneVirtualListParams {
|
||||
isActive: boolean;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
}
|
||||
|
||||
interface UseSftpPaneVirtualListResult {
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
rowHeight: number;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
}
|
||||
|
||||
export const useSftpPaneVirtualList = ({
|
||||
isActive,
|
||||
sortedDisplayFiles,
|
||||
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState(0);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !isActive) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
update();
|
||||
const raf1 = window.requestAnimationFrame(update);
|
||||
const raf2 = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf1);
|
||||
window.cancelAnimationFrame(raf2);
|
||||
};
|
||||
}, [isActive, sortedDisplayFiles.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !isActive || sortedDisplayFiles.length === 0) return;
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const rowElement = container.querySelector(
|
||||
'[data-sftp-row="true"]',
|
||||
) as HTMLElement | null;
|
||||
if (!rowElement) return;
|
||||
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
|
||||
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
|
||||
setRowHeight(nextHeight);
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isActive, rowHeight, sortedDisplayFiles.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileListScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!isActive) return;
|
||||
const nextTop = e.currentTarget.scrollTop;
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
setScrollTop(nextTop);
|
||||
});
|
||||
},
|
||||
[isActive],
|
||||
);
|
||||
|
||||
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
|
||||
const overscan = 6;
|
||||
const canVirtualize = isActive && viewportHeight > 0 && rowHeight > 0;
|
||||
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
|
||||
const totalHeightLocal = shouldVirtualizeLocal
|
||||
? sortedDisplayFiles.length * rowHeight
|
||||
: 0;
|
||||
const startIndex = shouldVirtualizeLocal
|
||||
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
||||
: 0;
|
||||
const endIndex = shouldVirtualizeLocal
|
||||
? Math.min(
|
||||
sortedDisplayFiles.length - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
|
||||
)
|
||||
: sortedDisplayFiles.length - 1;
|
||||
const visibleRowsLocal = shouldVirtualizeLocal
|
||||
? sortedDisplayFiles
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.map((entry, idx) => ({
|
||||
entry,
|
||||
index: startIndex + idx,
|
||||
top: (startIndex + idx) * rowHeight,
|
||||
}))
|
||||
: sortedDisplayFiles.map((entry, index) => ({
|
||||
entry,
|
||||
index,
|
||||
top: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
shouldVirtualize: shouldVirtualizeLocal,
|
||||
totalHeight: totalHeightLocal,
|
||||
visibleRows: visibleRowsLocal,
|
||||
};
|
||||
}, [isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
|
||||
|
||||
return {
|
||||
fileListRef,
|
||||
rowHeight,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
};
|
||||
};
|
||||
441
components/sftp/hooks/useSftpViewFileOps.ts
Normal file
441
components/sftp/hooks/useSftpViewFileOps.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
|
||||
interface UseSftpViewFileOpsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
behaviorRef: MutableRefObject<string>;
|
||||
autoSyncRef: MutableRefObject<boolean>;
|
||||
getOpenerForFileRef: MutableRefObject<
|
||||
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
|
||||
>;
|
||||
setOpenerForExtension: (
|
||||
extension: string,
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpViewFileOpsResult {
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
|
||||
setPermissionsState: React.Dispatch<
|
||||
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right" } | null>
|
||||
>;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
textEditorTarget: {
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null;
|
||||
setTextEditorTarget: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null>
|
||||
>;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: React.Dispatch<React.SetStateAction<string>>;
|
||||
loadingTextContent: boolean;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fileOpenerTarget: {
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null;
|
||||
setFileOpenerTarget: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null>
|
||||
>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
onEditPermissionsLeft: (file: SftpFileEntry) => void;
|
||||
onEditPermissionsRight: (file: SftpFileEntry) => void;
|
||||
onOpenEntryLeft: (entry: SftpFileEntry) => void;
|
||||
onOpenEntryRight: (entry: SftpFileEntry) => void;
|
||||
onEditFileLeft: (file: SftpFileEntry) => void;
|
||||
onEditFileRight: (file: SftpFileEntry) => void;
|
||||
onOpenFileLeft: (file: SftpFileEntry) => void;
|
||||
onOpenFileRight: (file: SftpFileEntry) => void;
|
||||
onOpenFileWithLeft: (file: SftpFileEntry) => void;
|
||||
onOpenFileWithRight: (file: SftpFileEntry) => void;
|
||||
onDownloadFileLeft: (file: SftpFileEntry) => void;
|
||||
onDownloadFileRight: (file: SftpFileEntry) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer) => void;
|
||||
onUploadExternalFilesRight: (dataTransfer: DataTransfer) => void;
|
||||
}
|
||||
|
||||
export const useSftpViewFileOps = ({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
} | null>(null);
|
||||
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null>(null);
|
||||
|
||||
const onEditPermissionsLeft = useCallback(
|
||||
(file: SftpFileEntry) => setPermissionsState({ file, side: "left" }),
|
||||
[],
|
||||
);
|
||||
const onEditPermissionsRight = useCallback(
|
||||
(file: SftpFileEntry) => setPermissionsState({ file, side: "right" }),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget({ file, side, fullPath });
|
||||
|
||||
const content = await sftpRef.current.readTextFile(side, fullPath);
|
||||
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load file", "SFTP");
|
||||
setTextEditorTarget(null);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleOpenFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const savedOpener = getOpenerForFileRef.current(file.name);
|
||||
|
||||
if (savedOpener && savedOpener.openerType) {
|
||||
if (savedOpener.openerType === "builtin-editor") {
|
||||
handleEditFileForSide(side, file);
|
||||
return;
|
||||
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
|
||||
try {
|
||||
await sftpRef.current.downloadToTempAndOpen(
|
||||
side,
|
||||
fullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current },
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget({ file, side, fullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
},
|
||||
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
|
||||
);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(
|
||||
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.file.name);
|
||||
setOpenerForExtension(ext, openerType, systemApp);
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === "builtin-editor") {
|
||||
handleEditFileForSide(fileOpenerTarget.side, fileOpenerTarget.file);
|
||||
} else if (openerType === "system-app" && systemApp) {
|
||||
try {
|
||||
await sftpRef.current.downloadToTempAndOpen(
|
||||
fileOpenerTarget.side,
|
||||
fileOpenerTarget.fullPath,
|
||||
fileOpenerTarget.file.name,
|
||||
systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current },
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
},
|
||||
[fileOpenerTarget, setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
|
||||
);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
const result = await sftpRef.current.selectApplication();
|
||||
if (result) {
|
||||
return { path: result.path, name: result.name };
|
||||
}
|
||||
return null;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleSaveTextFile = useCallback(
|
||||
async (content: string) => {
|
||||
if (!textEditorTarget) return;
|
||||
|
||||
await sftpRef.current.writeTextFile(
|
||||
textEditorTarget.side,
|
||||
textEditorTarget.fullPath,
|
||||
content,
|
||||
);
|
||||
},
|
||||
[textEditorTarget, sftpRef],
|
||||
);
|
||||
|
||||
const onEditFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleEditFileForSide("left", file),
|
||||
[handleEditFileForSide],
|
||||
);
|
||||
const onEditFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleEditFileForSide("right", file),
|
||||
[handleEditFileForSide],
|
||||
);
|
||||
const onOpenFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileForSide("left", file),
|
||||
[handleOpenFileForSide],
|
||||
);
|
||||
const onOpenFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileForSide("right", file),
|
||||
[handleOpenFileForSide],
|
||||
);
|
||||
|
||||
const handleOpenFileWithForSide = useCallback(
|
||||
(side: "left" | "right", file: SftpFileEntry) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
setFileOpenerTarget({ file, side, fullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const onOpenFileWithLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileWithForSide("left", file),
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
const onOpenFileWithRight = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileWithForSide("right", file),
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesForSide = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer) => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
|
||||
|
||||
// Check if upload was cancelled
|
||||
if (results.some((r) => r.cancelled)) {
|
||||
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = results.filter((r) => !r.success && !r.cancelled).length;
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
|
||||
if (failCount === 0) {
|
||||
const message =
|
||||
successCount === 1
|
||||
? `${t("sftp.upload")}: ${results[0].fileName}`
|
||||
: `${t("sftp.uploadFiles")}: ${successCount}`;
|
||||
toast.success(message, "SFTP");
|
||||
} else {
|
||||
const failedFiles = results.filter((r) => !r.success && !r.cancelled);
|
||||
failedFiles.forEach((failed) => {
|
||||
const errorMsg = failed.error ? ` - ${failed.error}` : "";
|
||||
toast.error(
|
||||
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
|
||||
"SFTP",
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[SftpView] Failed to upload external files:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[sftpRef, t],
|
||||
);
|
||||
|
||||
const onUploadExternalFilesLeft = useCallback(
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const onUploadExternalFilesRight = useCallback(
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const handleDownloadFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[sftpRef, t],
|
||||
);
|
||||
|
||||
const onDownloadFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const onDownloadFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const onOpenEntryLeft = useCallback(
|
||||
(entry: SftpFileEntry) => {
|
||||
const isDir = isNavigableDirectory(entry);
|
||||
|
||||
if (entry.name === ".." || isDir) {
|
||||
sftpRef.current.openEntry("left", entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (behaviorRef.current === "transfer") {
|
||||
const fileData = [{
|
||||
name: entry.name,
|
||||
isDirectory: isDir,
|
||||
}];
|
||||
sftpRef.current.startTransfer(fileData, "left", "right");
|
||||
} else {
|
||||
onOpenFileLeft(entry);
|
||||
}
|
||||
},
|
||||
[sftpRef, onOpenFileLeft, behaviorRef],
|
||||
);
|
||||
|
||||
const onOpenEntryRight = useCallback(
|
||||
(entry: SftpFileEntry) => {
|
||||
const isDir = isNavigableDirectory(entry);
|
||||
|
||||
if (entry.name === ".." || isDir) {
|
||||
sftpRef.current.openEntry("right", entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (behaviorRef.current === "transfer") {
|
||||
const fileData = [{
|
||||
name: entry.name,
|
||||
isDirectory: isDir,
|
||||
}];
|
||||
sftpRef.current.startTransfer(fileData, "right", "left");
|
||||
} else {
|
||||
onOpenFileRight(entry);
|
||||
}
|
||||
},
|
||||
[sftpRef, onOpenFileRight, behaviorRef],
|
||||
);
|
||||
|
||||
return {
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onEditPermissionsLeft,
|
||||
onEditPermissionsRight,
|
||||
onOpenEntryLeft,
|
||||
onOpenEntryRight,
|
||||
onEditFileLeft,
|
||||
onEditFileRight,
|
||||
onOpenFileLeft,
|
||||
onOpenFileRight,
|
||||
onOpenFileWithLeft,
|
||||
onOpenFileWithRight,
|
||||
onDownloadFileLeft,
|
||||
onDownloadFileRight,
|
||||
onUploadExternalFilesLeft,
|
||||
onUploadExternalFilesRight,
|
||||
};
|
||||
};
|
||||
224
components/sftp/hooks/useSftpViewPaneActions.ts
Normal file
224
components/sftp/hooks/useSftpViewPaneActions.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { SftpDragCallbacks } from "../SftpContext";
|
||||
|
||||
interface UseSftpViewPaneActionsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
}
|
||||
|
||||
interface UseSftpViewPaneActionsResult {
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onDisconnectLeft: () => void;
|
||||
onDisconnectRight: () => void;
|
||||
onNavigateToLeft: (path: string) => void;
|
||||
onNavigateToRight: (path: string) => void;
|
||||
onNavigateUpLeft: () => void;
|
||||
onNavigateUpRight: () => void;
|
||||
onRefreshLeft: () => void;
|
||||
onRefreshRight: () => void;
|
||||
onSetFilenameEncodingLeft: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
|
||||
onSetFilenameEncodingRight: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
|
||||
onToggleSelectionLeft: (name: string, multi: boolean) => void;
|
||||
onToggleSelectionRight: (name: string, multi: boolean) => void;
|
||||
onRangeSelectLeft: (fileNames: string[]) => void;
|
||||
onRangeSelectRight: (fileNames: string[]) => void;
|
||||
onClearSelectionLeft: () => void;
|
||||
onClearSelectionRight: () => void;
|
||||
onSetFilterLeft: (filter: string) => void;
|
||||
onSetFilterRight: (filter: string) => void;
|
||||
onCreateDirectoryLeft: (name: string) => void;
|
||||
onCreateDirectoryRight: (name: string) => void;
|
||||
onCreateFileLeft: (name: string) => void;
|
||||
onCreateFileRight: (name: string) => void;
|
||||
onDeleteFilesLeft: (names: string[]) => void;
|
||||
onDeleteFilesRight: (names: string[]) => void;
|
||||
onRenameFileLeft: (old: string, newName: string) => void;
|
||||
onRenameFileRight: (old: string, newName: string) => void;
|
||||
onCopyToOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onCopyToOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onReceiveFromOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onReceiveFromOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
}
|
||||
|
||||
export const useSftpViewPaneActions = ({
|
||||
sftpRef,
|
||||
}: UseSftpViewPaneActionsParams): UseSftpViewPaneActionsResult => {
|
||||
const [draggedFiles, setDraggedFiles] = useState<
|
||||
{ name: string; isDirectory: boolean; side: "left" | "right" }[] | null
|
||||
>(null);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(
|
||||
files: { name: string; isDirectory: boolean }[],
|
||||
side: "left" | "right",
|
||||
) => {
|
||||
setDraggedFiles(files.map((f) => ({ ...f, side })));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedFiles(null);
|
||||
}, []);
|
||||
|
||||
const onCopyToOtherPaneLeft = useCallback(
|
||||
(files: { name: string; isDirectory: boolean }[]) =>
|
||||
sftpRef.current.startTransfer(files, "left", "right"),
|
||||
[sftpRef],
|
||||
);
|
||||
const onCopyToOtherPaneRight = useCallback(
|
||||
(files: { name: string; isDirectory: boolean }[]) =>
|
||||
sftpRef.current.startTransfer(files, "right", "left"),
|
||||
[sftpRef],
|
||||
);
|
||||
const onReceiveFromOtherPaneLeft = useCallback(
|
||||
(files: { name: string; isDirectory: boolean }[]) =>
|
||||
sftpRef.current.startTransfer(files, "right", "left"),
|
||||
[sftpRef],
|
||||
);
|
||||
const onReceiveFromOtherPaneRight = useCallback(
|
||||
(files: { name: string; isDirectory: boolean }[]) =>
|
||||
sftpRef.current.startTransfer(files, "left", "right"),
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const onConnectLeft = useCallback(
|
||||
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("left", host),
|
||||
[sftpRef],
|
||||
);
|
||||
const onConnectRight = useCallback(
|
||||
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("right", host),
|
||||
[sftpRef],
|
||||
);
|
||||
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
|
||||
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
|
||||
const onNavigateToLeft = useCallback(
|
||||
(path: string) => sftpRef.current.navigateTo("left", path),
|
||||
[sftpRef],
|
||||
);
|
||||
const onNavigateToRight = useCallback(
|
||||
(path: string) => sftpRef.current.navigateTo("right", path),
|
||||
[sftpRef],
|
||||
);
|
||||
const onNavigateUpLeft = useCallback(() => sftpRef.current.navigateUp("left"), [sftpRef]);
|
||||
const onNavigateUpRight = useCallback(() => sftpRef.current.navigateUp("right"), [sftpRef]);
|
||||
const onRefreshLeft = useCallback(() => sftpRef.current.refresh("left"), [sftpRef]);
|
||||
const onRefreshRight = useCallback(() => sftpRef.current.refresh("right"), [sftpRef]);
|
||||
const onSetFilenameEncodingLeft = useCallback(
|
||||
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
|
||||
sftpRef.current.setFilenameEncoding("left", encoding),
|
||||
[sftpRef],
|
||||
);
|
||||
const onSetFilenameEncodingRight = useCallback(
|
||||
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
|
||||
sftpRef.current.setFilenameEncoding("right", encoding),
|
||||
[sftpRef],
|
||||
);
|
||||
const onToggleSelectionLeft = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
|
||||
[sftpRef],
|
||||
);
|
||||
const onToggleSelectionRight = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
|
||||
[sftpRef],
|
||||
);
|
||||
const onRangeSelectLeft = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
|
||||
[sftpRef],
|
||||
);
|
||||
const onRangeSelectRight = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
|
||||
[sftpRef],
|
||||
);
|
||||
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
|
||||
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
|
||||
const onSetFilterLeft = useCallback(
|
||||
(filter: string) => sftpRef.current.setFilter("left", filter),
|
||||
[sftpRef],
|
||||
);
|
||||
const onSetFilterRight = useCallback(
|
||||
(filter: string) => sftpRef.current.setFilter("right", filter),
|
||||
[sftpRef],
|
||||
);
|
||||
const onCreateDirectoryLeft = useCallback(
|
||||
(name: string) => sftpRef.current.createDirectory("left", name),
|
||||
[sftpRef],
|
||||
);
|
||||
const onCreateDirectoryRight = useCallback(
|
||||
(name: string) => sftpRef.current.createDirectory("right", name),
|
||||
[sftpRef],
|
||||
);
|
||||
const onCreateFileLeft = useCallback(
|
||||
(name: string) => sftpRef.current.createFile("left", name),
|
||||
[sftpRef],
|
||||
);
|
||||
const onCreateFileRight = useCallback(
|
||||
(name: string) => sftpRef.current.createFile("right", name),
|
||||
[sftpRef],
|
||||
);
|
||||
const onDeleteFilesLeft = useCallback(
|
||||
(names: string[]) => sftpRef.current.deleteFiles("left", names),
|
||||
[sftpRef],
|
||||
);
|
||||
const onDeleteFilesRight = useCallback(
|
||||
(names: string[]) => sftpRef.current.deleteFiles("right", names),
|
||||
[sftpRef],
|
||||
);
|
||||
const onRenameFileLeft = useCallback(
|
||||
(old: string, newName: string) => sftpRef.current.renameFile("left", old, newName),
|
||||
[sftpRef],
|
||||
);
|
||||
const onRenameFileRight = useCallback(
|
||||
(old: string, newName: string) => sftpRef.current.renameFile("right", old, newName),
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const dragCallbacks = useMemo<SftpDragCallbacks>(
|
||||
() => ({
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
}),
|
||||
[handleDragStart, handleDragEnd],
|
||||
);
|
||||
|
||||
return {
|
||||
dragCallbacks,
|
||||
draggedFiles,
|
||||
onConnectLeft,
|
||||
onConnectRight,
|
||||
onDisconnectLeft,
|
||||
onDisconnectRight,
|
||||
onNavigateToLeft,
|
||||
onNavigateToRight,
|
||||
onNavigateUpLeft,
|
||||
onNavigateUpRight,
|
||||
onRefreshLeft,
|
||||
onRefreshRight,
|
||||
onSetFilenameEncodingLeft,
|
||||
onSetFilenameEncodingRight,
|
||||
onToggleSelectionLeft,
|
||||
onToggleSelectionRight,
|
||||
onRangeSelectLeft,
|
||||
onRangeSelectRight,
|
||||
onClearSelectionLeft,
|
||||
onClearSelectionRight,
|
||||
onSetFilterLeft,
|
||||
onSetFilterRight,
|
||||
onCreateDirectoryLeft,
|
||||
onCreateDirectoryRight,
|
||||
onCreateFileLeft,
|
||||
onCreateFileRight,
|
||||
onDeleteFilesLeft,
|
||||
onDeleteFilesRight,
|
||||
onRenameFileLeft,
|
||||
onRenameFileRight,
|
||||
onCopyToOtherPaneLeft,
|
||||
onCopyToOtherPaneRight,
|
||||
onReceiveFromOtherPaneLeft,
|
||||
onReceiveFromOtherPaneRight,
|
||||
};
|
||||
};
|
||||
124
components/sftp/hooks/useSftpViewPaneCallbacks.ts
Normal file
124
components/sftp/hooks/useSftpViewPaneCallbacks.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { SftpPaneCallbacks } from "../SftpContext";
|
||||
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
|
||||
import { useSftpViewFileOps } from "./useSftpViewFileOps";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpViewPaneCallbacksParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
behaviorRef: MutableRefObject<string>;
|
||||
autoSyncRef: MutableRefObject<boolean>;
|
||||
getOpenerForFileRef: MutableRefObject<
|
||||
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
|
||||
>;
|
||||
setOpenerForExtension: (
|
||||
extension: string,
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
export const useSftpViewPaneCallbacks = ({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
}: UseSftpViewPaneCallbacksParams) => {
|
||||
const paneActions = useSftpViewPaneActions({ sftpRef });
|
||||
const fileOps = useSftpViewFileOps({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
});
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
|
||||
const leftCallbacks = useMemo<SftpPaneCallbacks>(
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectLeft,
|
||||
onDisconnect: paneActions.onDisconnectLeft,
|
||||
onNavigateTo: paneActions.onNavigateToLeft,
|
||||
onNavigateUp: paneActions.onNavigateUpLeft,
|
||||
onRefresh: paneActions.onRefreshLeft,
|
||||
onSetFilenameEncoding: paneActions.onSetFilenameEncodingLeft,
|
||||
onOpenEntry: fileOps.onOpenEntryLeft,
|
||||
onToggleSelection: paneActions.onToggleSelectionLeft,
|
||||
onRangeSelect: paneActions.onRangeSelectLeft,
|
||||
onClearSelection: paneActions.onClearSelectionLeft,
|
||||
onSetFilter: paneActions.onSetFilterLeft,
|
||||
onCreateDirectory: paneActions.onCreateDirectoryLeft,
|
||||
onCreateFile: paneActions.onCreateFileLeft,
|
||||
onDeleteFiles: paneActions.onDeleteFilesLeft,
|
||||
onRenameFile: paneActions.onRenameFileLeft,
|
||||
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
|
||||
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
|
||||
onEditPermissions: fileOps.onEditPermissionsLeft,
|
||||
onEditFile: fileOps.onEditFileLeft,
|
||||
onOpenFile: fileOps.onOpenFileLeft,
|
||||
onOpenFileWith: fileOps.onOpenFileWithLeft,
|
||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const rightCallbacks = useMemo<SftpPaneCallbacks>(
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectRight,
|
||||
onDisconnect: paneActions.onDisconnectRight,
|
||||
onNavigateTo: paneActions.onNavigateToRight,
|
||||
onNavigateUp: paneActions.onNavigateUpRight,
|
||||
onRefresh: paneActions.onRefreshRight,
|
||||
onSetFilenameEncoding: paneActions.onSetFilenameEncodingRight,
|
||||
onOpenEntry: fileOps.onOpenEntryRight,
|
||||
onToggleSelection: paneActions.onToggleSelectionRight,
|
||||
onRangeSelect: paneActions.onRangeSelectRight,
|
||||
onClearSelection: paneActions.onClearSelectionRight,
|
||||
onSetFilter: paneActions.onSetFilterRight,
|
||||
onCreateDirectory: paneActions.onCreateDirectoryRight,
|
||||
onCreateFile: paneActions.onCreateFileRight,
|
||||
onDeleteFiles: paneActions.onDeleteFilesRight,
|
||||
onRenameFile: paneActions.onRenameFileRight,
|
||||
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
|
||||
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
|
||||
onEditPermissions: fileOps.onEditPermissionsRight,
|
||||
onEditFile: fileOps.onEditFileRight,
|
||||
onOpenFile: fileOps.onOpenFileRight,
|
||||
onOpenFileWith: fileOps.onOpenFileWithRight,
|
||||
onDownloadFile: fileOps.onDownloadFileRight,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
return {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
dragCallbacks: paneActions.dragCallbacks,
|
||||
draggedFiles: paneActions.draggedFiles,
|
||||
permissionsState: fileOps.permissionsState,
|
||||
setPermissionsState: fileOps.setPermissionsState,
|
||||
showTextEditor: fileOps.showTextEditor,
|
||||
setShowTextEditor: fileOps.setShowTextEditor,
|
||||
textEditorTarget: fileOps.textEditorTarget,
|
||||
setTextEditorTarget: fileOps.setTextEditorTarget,
|
||||
textEditorContent: fileOps.textEditorContent,
|
||||
setTextEditorContent: fileOps.setTextEditorContent,
|
||||
loadingTextContent: fileOps.loadingTextContent,
|
||||
showFileOpenerDialog: fileOps.showFileOpenerDialog,
|
||||
setShowFileOpenerDialog: fileOps.setShowFileOpenerDialog,
|
||||
fileOpenerTarget: fileOps.fileOpenerTarget,
|
||||
setFileOpenerTarget: fileOps.setFileOpenerTarget,
|
||||
handleSaveTextFile: fileOps.handleSaveTextFile,
|
||||
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
|
||||
handleSelectSystemApp: fileOps.handleSelectSystemApp,
|
||||
};
|
||||
};
|
||||
159
components/sftp/hooks/useSftpViewTabs.ts
Normal file
159
components/sftp/hooks/useSftpViewTabs.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
|
||||
interface UseSftpViewTabsParams {
|
||||
sftp: SftpStateApi;
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
}
|
||||
|
||||
interface UseSftpViewTabsResult {
|
||||
leftPanes: SftpStateApi["leftPane"][];
|
||||
rightPanes: SftpStateApi["rightPane"][];
|
||||
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
|
||||
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
|
||||
showHostPickerLeft: boolean;
|
||||
showHostPickerRight: boolean;
|
||||
hostSearchLeft: string;
|
||||
hostSearchRight: string;
|
||||
setShowHostPickerLeft: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleAddTabLeft: () => void;
|
||||
handleAddTabRight: () => void;
|
||||
handleCloseTabLeft: (tabId: string) => void;
|
||||
handleCloseTabRight: (tabId: string) => void;
|
||||
handleSelectTabLeft: (tabId: string) => void;
|
||||
handleSelectTabRight: (tabId: string) => void;
|
||||
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
||||
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
||||
handleMoveTabFromLeftToRight: (tabId: string) => void;
|
||||
handleMoveTabFromRightToLeft: (tabId: string) => void;
|
||||
handleHostSelectLeft: (host: Host | "local") => void;
|
||||
handleHostSelectRight: (host: Host | "local") => void;
|
||||
}
|
||||
|
||||
export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
|
||||
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
|
||||
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
|
||||
const [hostSearchLeft, setHostSearchLeft] = useState("");
|
||||
const [hostSearchRight, setHostSearchRight] = useState("");
|
||||
|
||||
const handleAddTabLeft = useCallback(() => {
|
||||
sftpRef.current.addTab("left");
|
||||
setShowHostPickerLeft(true);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleAddTabRight = useCallback(() => {
|
||||
sftpRef.current.addTab("right");
|
||||
setShowHostPickerRight(true);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleCloseTabLeft = useCallback((tabId: string) => {
|
||||
sftpRef.current.closeTab("left", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleCloseTabRight = useCallback((tabId: string) => {
|
||||
sftpRef.current.closeTab("right", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleSelectTabLeft = useCallback((tabId: string) => {
|
||||
sftpRef.current.selectTab("left", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleSelectTabRight = useCallback((tabId: string) => {
|
||||
sftpRef.current.selectTab("right", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const leftPanes = useMemo(
|
||||
() => (sftp.leftTabs.tabs.length > 0 ? sftp.leftTabs.tabs : [sftp.leftPane]),
|
||||
[sftp.leftTabs.tabs, sftp.leftPane],
|
||||
);
|
||||
const rightPanes = useMemo(
|
||||
() => (sftp.rightTabs.tabs.length > 0 ? sftp.rightTabs.tabs : [sftp.rightPane]),
|
||||
[sftp.rightTabs.tabs, sftp.rightPane],
|
||||
);
|
||||
|
||||
const handleReorderTabsLeft = useCallback(
|
||||
(draggedId: string, targetId: string, position: "before" | "after") => {
|
||||
sftpRef.current.reorderTabs("left", draggedId, targetId, position);
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleReorderTabsRight = useCallback(
|
||||
(draggedId: string, targetId: string, position: "before" | "after") => {
|
||||
sftpRef.current.reorderTabs("right", draggedId, targetId, position);
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleMoveTabFromLeftToRight = useCallback((tabId: string) => {
|
||||
sftpRef.current.moveTabToOtherSide("left", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleMoveTabFromRightToLeft = useCallback((tabId: string) => {
|
||||
sftpRef.current.moveTabToOtherSide("right", tabId);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleHostSelectLeft = useCallback((host: Host | "local") => {
|
||||
sftpRef.current.connect("left", host);
|
||||
setShowHostPickerLeft(false);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleHostSelectRight = useCallback((host: Host | "local") => {
|
||||
sftpRef.current.connect("right", host);
|
||||
setShowHostPickerRight(false);
|
||||
}, [sftpRef]);
|
||||
|
||||
const leftTabsInfo = useMemo(
|
||||
() =>
|
||||
sftp.leftTabs.tabs.map((pane) => ({
|
||||
id: pane.id,
|
||||
label: pane.connection?.hostLabel || "New Tab",
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
})),
|
||||
[sftp.leftTabs.tabs],
|
||||
);
|
||||
|
||||
const rightTabsInfo = useMemo(
|
||||
() =>
|
||||
sftp.rightTabs.tabs.map((pane) => ({
|
||||
id: pane.id,
|
||||
label: pane.connection?.hostLabel || "New Tab",
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
})),
|
||||
[sftp.rightTabs.tabs],
|
||||
);
|
||||
|
||||
return {
|
||||
leftPanes,
|
||||
rightPanes,
|
||||
leftTabsInfo,
|
||||
rightTabsInfo,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
hostSearchRight,
|
||||
setShowHostPickerLeft,
|
||||
setShowHostPickerRight,
|
||||
setHostSearchLeft,
|
||||
setHostSearchRight,
|
||||
handleAddTabLeft,
|
||||
handleAddTabRight,
|
||||
handleCloseTabLeft,
|
||||
handleCloseTabRight,
|
||||
handleSelectTabLeft,
|
||||
handleSelectTabRight,
|
||||
handleReorderTabsLeft,
|
||||
handleReorderTabsRight,
|
||||
handleMoveTabFromLeftToRight,
|
||||
handleMoveTabFromRightToLeft,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
};
|
||||
};
|
||||
@@ -48,14 +48,14 @@ export const formatTransferBytes = (bytes: number): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date as YYYY-MM-DD HH:mm:ss in local timezone
|
||||
* Format date as YYYY-MM-DD hh:mm in local timezone
|
||||
*/
|
||||
export const formatDate = (timestamp: number | undefined): string => {
|
||||
if (!timestamp) return '--';
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '--';
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { RightClickBehavior } from '../../domain/models';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -26,6 +26,7 @@ export interface TerminalContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
hasSelection?: boolean;
|
||||
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
||||
keyBindings?: KeyBinding[];
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
onCopy?: () => void;
|
||||
onPaste?: () => void;
|
||||
@@ -41,6 +42,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
children,
|
||||
hasSelection = false,
|
||||
hotkeyScheme = 'mac',
|
||||
keyBindings,
|
||||
rightClickBehavior = 'context-menu',
|
||||
onCopy,
|
||||
onPaste,
|
||||
@@ -54,12 +56,24 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
const { t } = useI18n();
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
|
||||
const copyShortcut = isMac ? '⌘C' : 'Ctrl+Shift+C';
|
||||
const pasteShortcut = isMac ? '⌘V' : 'Ctrl+Shift+V';
|
||||
const selectAllShortcut = isMac ? '⌘A' : 'Ctrl+Shift+A';
|
||||
const splitHShortcut = isMac ? '⌘D' : 'Ctrl+Shift+D';
|
||||
const splitVShortcut = isMac ? '⌘E' : 'Ctrl+Shift+E';
|
||||
const clearShortcut = isMac ? '⌘K' : 'Ctrl+L';
|
||||
// Helper to get shortcut from keyBindings and format for display
|
||||
const getShortcut = (bindingId: string): string => {
|
||||
const binding = keyBindings?.find(b => b.id === bindingId);
|
||||
if (!binding) return '';
|
||||
const key = isMac ? binding.mac : binding.pc;
|
||||
if (!key || key === 'Disabled') return '';
|
||||
// Replace " + " with space for cleaner display (e.g., "⌘ + Shift + D" → "⌘ Shift D")
|
||||
return key.replace(/\s*\+\s*/g, ' ').trim();
|
||||
};
|
||||
|
||||
const copyShortcut = getShortcut('copy');
|
||||
const pasteShortcut = getShortcut('paste');
|
||||
const selectAllShortcut = getShortcut('select-all');
|
||||
const splitHShortcut = getShortcut('split-horizontal');
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
const clearShortcut = getShortcut('clear-buffer');
|
||||
|
||||
const showContextMenu = rightClickBehavior === 'context-menu';
|
||||
|
||||
const handleRightClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -76,71 +90,72 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
[rightClickBehavior, onPaste, onSelectWord],
|
||||
);
|
||||
|
||||
if (rightClickBehavior !== 'context-menu') {
|
||||
return (
|
||||
<div onContextMenu={handleRightClick} className="contents">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Always use ContextMenu wrapper to maintain consistent React tree structure
|
||||
// This prevents terminal from unmounting when rightClickBehavior changes
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t('terminal.menu.copy')}
|
||||
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onPaste}>
|
||||
<ClipboardPaste size={14} className="mr-2" />
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onSelectAll}>
|
||||
<TerminalIcon size={14} className="mr-2" />
|
||||
{t('terminal.menu.selectAll')}
|
||||
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
disabled={!showContextMenu}
|
||||
onContextMenu={!showContextMenu ? handleRightClick : undefined}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
{showContextMenu && (
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t('terminal.menu.copy')}
|
||||
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onPaste}>
|
||||
<ClipboardPaste size={14} className="mr-2" />
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onSelectAll}>
|
||||
<TerminalIcon size={14} className="mr-2" />
|
||||
{t('terminal.menu.selectAll')}
|
||||
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem onClick={onSplitVertical}>
|
||||
<SplitSquareHorizontal size={14} className="mr-2" />
|
||||
{t('terminal.menu.splitHorizontal')}
|
||||
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onSplitHorizontal}>
|
||||
<SplitSquareVertical size={14} className="mr-2" />
|
||||
{t('terminal.menu.splitVertical')}
|
||||
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onSplitVertical}>
|
||||
<SplitSquareHorizontal size={14} className="mr-2" />
|
||||
{t('terminal.menu.splitHorizontal')}
|
||||
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onSplitHorizontal}>
|
||||
<SplitSquareVertical size={14} className="mr-2" />
|
||||
{t('terminal.menu.splitVertical')}
|
||||
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem onClick={onClear}>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t('terminal.menu.clearBuffer')}
|
||||
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onClear}>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t('terminal.menu.clearBuffer')}
|
||||
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
{onClose && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={onClose}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t('terminal.menu.closeTerminal')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
{onClose && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={onClose}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t('terminal.menu.closeTerminal')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalContextMenu;
|
||||
|
||||
|
||||
201
components/terminal/hooks/useServerStats.ts
Normal file
201
components/terminal/hooks/useServerStats.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface DiskInfo {
|
||||
mountPoint: string;
|
||||
used: number; // Used in GB
|
||||
total: number; // Total in GB
|
||||
percent: number; // Usage percentage
|
||||
}
|
||||
|
||||
export interface NetInterfaceInfo {
|
||||
name: string; // Interface name (e.g., eth0, ens33)
|
||||
rxBytes: number; // Total received bytes
|
||||
txBytes: number; // Total transmitted bytes
|
||||
rxSpeed: number; // Receive speed (bytes/sec)
|
||||
txSpeed: number; // Transmit speed (bytes/sec)
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: string;
|
||||
memPercent: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
cpu: number | null; // CPU usage percentage (0-100)
|
||||
cpuCores: number | null; // Number of CPU cores
|
||||
cpuPerCore: number[]; // Per-core CPU usage array
|
||||
memTotal: number | null; // Total memory in MB
|
||||
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
|
||||
memFree: number | null; // Free memory in MB
|
||||
memBuffers: number | null; // Buffers in MB
|
||||
memCached: number | null; // Cached in MB
|
||||
topProcesses: ProcessInfo[]; // Top 10 processes by memory
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
diskTotal: number | null; // Total disk in GB
|
||||
disks: DiskInfo[]; // All mounted disks
|
||||
netRxSpeed: number; // Total network receive speed (bytes/sec)
|
||||
netTxSpeed: number; // Total network transmit speed (bytes/sec)
|
||||
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
|
||||
lastUpdated: number | null; // Timestamp of last successful update
|
||||
}
|
||||
|
||||
interface UseServerStatsOptions {
|
||||
sessionId: string;
|
||||
enabled: boolean; // Whether stats collection is enabled (from settings)
|
||||
refreshInterval: number; // Refresh interval in seconds
|
||||
isLinux: boolean; // Only collect stats for Linux servers
|
||||
isConnected: boolean; // Only collect when connected
|
||||
}
|
||||
|
||||
export function useServerStats({
|
||||
sessionId,
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isLinux,
|
||||
isConnected,
|
||||
}: UseServerStatsOptions) {
|
||||
const [stats, setStats] = useState<ServerStats>({
|
||||
cpu: null,
|
||||
cpuCores: null,
|
||||
cpuPerCore: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
memBuffers: null,
|
||||
memCached: null,
|
||||
topProcesses: [],
|
||||
diskPercent: null,
|
||||
diskUsed: null,
|
||||
diskTotal: null,
|
||||
disks: [],
|
||||
netRxSpeed: 0,
|
||||
netTxSpeed: 0,
|
||||
netInterfaces: [],
|
||||
lastUpdated: null,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!enabled || !isLinux || !isConnected || !sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getServerStats) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await bridge.getServerStats(sessionId);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (result.success && result.stats) {
|
||||
setStats({
|
||||
cpu: result.stats.cpu,
|
||||
cpuCores: result.stats.cpuCores,
|
||||
cpuPerCore: result.stats.cpuPerCore || [],
|
||||
memTotal: result.stats.memTotal,
|
||||
memUsed: result.stats.memUsed,
|
||||
memFree: result.stats.memFree,
|
||||
memBuffers: result.stats.memBuffers,
|
||||
memCached: result.stats.memCached,
|
||||
topProcesses: result.stats.topProcesses || [],
|
||||
diskPercent: result.stats.diskPercent,
|
||||
diskUsed: result.stats.diskUsed,
|
||||
diskTotal: result.stats.diskTotal,
|
||||
disks: result.stats.disks || [],
|
||||
netRxSpeed: result.stats.netRxSpeed || 0,
|
||||
netTxSpeed: result.stats.netTxSpeed || 0,
|
||||
netInterfaces: result.stats.netInterfaces || [],
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
} else if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [sessionId, enabled, isLinux, isConnected]);
|
||||
|
||||
// Initial fetch and periodic refresh
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Don't run if not enabled or not a Linux system
|
||||
if (!enabled || !isLinux || !isConnected) {
|
||||
// Reset stats when disabled or not connected
|
||||
setStats({
|
||||
cpu: null,
|
||||
cpuCores: null,
|
||||
cpuPerCore: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
memBuffers: null,
|
||||
memCached: null,
|
||||
topProcesses: [],
|
||||
diskPercent: null,
|
||||
diskUsed: null,
|
||||
diskTotal: null,
|
||||
disks: [],
|
||||
netRxSpeed: 0,
|
||||
netTxSpeed: 0,
|
||||
netInterfaces: [],
|
||||
lastUpdated: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial fetch with a small delay to let the connection stabilize
|
||||
const initialTimer = setTimeout(() => {
|
||||
fetchStats();
|
||||
}, 2000);
|
||||
|
||||
// Set up periodic refresh
|
||||
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
|
||||
intervalRef.current = setInterval(fetchStats, intervalMs);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimeout(initialTimer);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
|
||||
|
||||
// Manual refresh function
|
||||
const refresh = useCallback(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export type { TerminalContextMenuProps } from './TerminalContextMenu';
|
||||
export { TerminalSearchBar } from './TerminalSearchBar';
|
||||
export type { TerminalSearchBarProps } from './TerminalSearchBar';
|
||||
|
||||
export { createHighlightProcessor, highlightKeywords, compileHighlightRules } from './keywordHighlight';
|
||||
export { KeywordHighlighter } from './keywordHighlight';
|
||||
|
||||
export { useTerminalSearch } from './hooks/useTerminalSearch';
|
||||
export { useTerminalContextActions } from './hooks/useTerminalContextActions';
|
||||
|
||||
@@ -1,136 +1,218 @@
|
||||
|
||||
import { Terminal as XTerm, IDecoration, IDisposable, IMarker, IBufferLine } from "@xterm/xterm";
|
||||
import { KeywordHighlightRule } from "../../types";
|
||||
|
||||
// ESC character as unicode escape for ESLint compatibility
|
||||
const ESC = "\u001b";
|
||||
|
||||
/**
|
||||
* Convert a hex color to ANSI 24-bit true color escape sequence
|
||||
* Format: ESC[38;2;R;G;Bm for foreground color
|
||||
*/
|
||||
function hexToAnsi(hex: string): string {
|
||||
// Remove # if present
|
||||
const cleanHex = hex.replace("#", "");
|
||||
const r = parseInt(cleanHex.slice(0, 2), 16);
|
||||
const g = parseInt(cleanHex.slice(2, 4), 16);
|
||||
const b = parseInt(cleanHex.slice(4, 6), 16);
|
||||
return `${ESC}[38;2;${r};${g};${b}m`;
|
||||
}
|
||||
|
||||
const ANSI_RESET = `${ESC}[0m`;
|
||||
|
||||
// Regex to match ANSI escape sequences (to skip them during highlighting)
|
||||
// Using RegExp constructor to avoid ESLint control character warning
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_ESCAPE_REGEX = /\u001b\[[0-9;]*[a-zA-Z]/g;
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
|
||||
|
||||
/** Pre-compiled rule with regex ready for matching */
|
||||
interface CompiledRule {
|
||||
regex: RegExp;
|
||||
ansiColor: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-compile keyword highlight rules for better performance
|
||||
* Manages terminal decorations for keyword highlighting.
|
||||
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
|
||||
* This ensures zero impact on scrolling performance ("lazy" highlighting).
|
||||
*/
|
||||
export function compileHighlightRules(
|
||||
rules: KeywordHighlightRule[],
|
||||
enabled: boolean
|
||||
): CompiledRule[] {
|
||||
if (!enabled) return [];
|
||||
|
||||
return rules
|
||||
.filter((rule) => rule.enabled && rule.patterns.length > 0)
|
||||
.map((rule) => {
|
||||
// Combine all patterns with OR, case-insensitive
|
||||
const combinedPattern = rule.patterns.join("|");
|
||||
return {
|
||||
regex: new RegExp(`(${combinedPattern})`, "gi"),
|
||||
ansiColor: hexToAnsi(rule.color),
|
||||
};
|
||||
});
|
||||
}
|
||||
export class KeywordHighlighter implements IDisposable {
|
||||
private term: XTerm;
|
||||
private compiledRules: CompiledRule[] = [];
|
||||
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
|
||||
private debounceTimer: NodeJS.Timeout | null = null;
|
||||
private enabled: boolean = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
|
||||
/**
|
||||
* Apply keyword highlighting to terminal output
|
||||
* This processes text and adds ANSI color codes for matched keywords
|
||||
*
|
||||
* Note: This is a simplified approach that works well for most cases.
|
||||
* It processes the text while preserving existing ANSI escape sequences.
|
||||
*/
|
||||
export function highlightKeywords(
|
||||
text: string,
|
||||
compiledRules: CompiledRule[]
|
||||
): string {
|
||||
if (compiledRules.length === 0 || !text) {
|
||||
return text;
|
||||
constructor(term: XTerm) {
|
||||
this.term = term;
|
||||
|
||||
// Debug logging
|
||||
console.log('[KeywordHighlighter] Initialized');
|
||||
|
||||
// Hook into terminal events to trigger highlighting
|
||||
this.disposables.push(
|
||||
// When user scrolls, refresh visible area
|
||||
this.term.onScroll(() => {
|
||||
// console.log('[KeywordHighlighter] onScroll');
|
||||
this.triggerRefresh();
|
||||
}),
|
||||
// When new data is written, refresh
|
||||
this.term.onWriteParsed(() => {
|
||||
// console.log('[KeywordHighlighter] onWriteParsed');
|
||||
this.triggerRefresh();
|
||||
}),
|
||||
// Also refresh on resize as viewport content changes
|
||||
this.term.onResize(() => this.triggerRefresh())
|
||||
);
|
||||
}
|
||||
|
||||
// Split text into segments: ANSI sequences and regular text
|
||||
const segments: Array<{ isAnsi: boolean; content: string }> = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
// Find all ANSI escape sequences
|
||||
let match: RegExpExecArray | null;
|
||||
const ansiRegex = new RegExp(ANSI_ESCAPE_REGEX.source, "g");
|
||||
|
||||
while ((match = ansiRegex.exec(text)) !== null) {
|
||||
// Add text before this ANSI sequence
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
// Add the ANSI sequence itself
|
||||
segments.push({
|
||||
isAnsi: true,
|
||||
content: match[0],
|
||||
});
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text after last ANSI sequence
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Process only non-ANSI segments
|
||||
const processedSegments = segments.map((segment) => {
|
||||
if (segment.isAnsi) {
|
||||
return segment.content;
|
||||
}
|
||||
|
||||
let processed = segment.content;
|
||||
|
||||
// Apply each rule
|
||||
for (const rule of compiledRules) {
|
||||
processed = processed.replace(rule.regex, (matched) => {
|
||||
return `${rule.ansiColor}${matched}${ANSI_RESET}`;
|
||||
});
|
||||
}
|
||||
|
||||
return processed;
|
||||
});
|
||||
|
||||
return processedSegments.join("");
|
||||
}
|
||||
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
|
||||
this.enabled = enabled;
|
||||
|
||||
/**
|
||||
* Create a highlight processor function with pre-compiled rules
|
||||
* Use this for better performance when processing multiple chunks
|
||||
*/
|
||||
export function createHighlightProcessor(
|
||||
rules: KeywordHighlightRule[],
|
||||
enabled: boolean
|
||||
): (text: string) => string {
|
||||
const compiledRules = compileHighlightRules(rules, enabled);
|
||||
|
||||
if (compiledRules.length === 0) {
|
||||
// Return identity function if no rules are enabled
|
||||
return (text: string) => text;
|
||||
// Pre-compile all patterns into regexes for better performance
|
||||
// This avoids creating new RegExp objects on every viewport refresh
|
||||
this.compiledRules = [];
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled || rule.patterns.length === 0) continue;
|
||||
for (const pattern of rule.patterns) {
|
||||
try {
|
||||
this.compiledRules.push({
|
||||
regex: new RegExp(pattern, "gi"),
|
||||
color: rule.color,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Invalid regex pattern:", pattern, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing and force an immediate refresh if enabling
|
||||
this.clearDecorations();
|
||||
if (this.enabled && this.compiledRules.length > 0) {
|
||||
this.triggerRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.clearDecorations();
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
this.disposables = [];
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
}
|
||||
|
||||
private triggerRefresh() {
|
||||
if (!this.enabled || this.compiledRules.length === 0) return;
|
||||
|
||||
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
|
||||
// These apps manage their own highlighting and have rapid repaints.
|
||||
if (this.term.buffer.active.type === 'alternate') {
|
||||
if (this.decorations.length > 0) {
|
||||
this.clearDecorations();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
|
||||
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
|
||||
}
|
||||
|
||||
private clearDecorations() {
|
||||
this.decorations.forEach(({ decoration, marker }) => {
|
||||
decoration.dispose();
|
||||
marker.dispose();
|
||||
});
|
||||
this.decorations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mapping from string character index to terminal cell column.
|
||||
* This handles wide characters (CJK, emoji) and combining characters correctly.
|
||||
*
|
||||
* For example, with "A中B":
|
||||
* - String indices: 0='A', 1='中', 2='B'
|
||||
* - Cell columns: 0='A', 1='中'(width 2), 3='B'
|
||||
* - Result map: [0, 1, 3, 4] (includes end position)
|
||||
*/
|
||||
private buildStringToCellMap(line: IBufferLine): number[] {
|
||||
const map: number[] = [];
|
||||
let cellCol = 0;
|
||||
|
||||
for (let col = 0; col < line.length; col++) {
|
||||
const cell = line.getCell(col);
|
||||
if (!cell) break;
|
||||
|
||||
const chars = cell.getChars();
|
||||
const width = cell.getWidth();
|
||||
|
||||
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
|
||||
if (width === 0) continue;
|
||||
|
||||
// Map each character in this cell to the current cell column
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
map.push(cellCol);
|
||||
}
|
||||
|
||||
cellCol += width;
|
||||
}
|
||||
|
||||
// Add final position for calculating end column of matches
|
||||
map.push(cellCol);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private refreshViewport() {
|
||||
// Safety check just in case
|
||||
if (!this.term?.buffer?.active) return;
|
||||
|
||||
const buffer = this.term.buffer.active;
|
||||
const viewportY = buffer.viewportY;
|
||||
const rows = this.term.rows;
|
||||
const cursorY = buffer.cursorY;
|
||||
const baseY = buffer.baseY;
|
||||
const cursorAbsoluteY = baseY + cursorY;
|
||||
|
||||
// Clear old decorations to avoid duplicates/memory leaks
|
||||
this.clearDecorations();
|
||||
|
||||
// Iterate only over the visible rows
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const lineY = viewportY + y;
|
||||
const line = buffer.getLine(lineY);
|
||||
if (!line) continue;
|
||||
|
||||
const lineText = line.translateToString(true); // true = trim right whitespace
|
||||
if (!lineText) continue;
|
||||
|
||||
// Build mapping from string index to cell column for wide char support
|
||||
const cellMap = this.buildStringToCellMap(line);
|
||||
|
||||
// Process each pre-compiled rule
|
||||
for (const { regex, color } of this.compiledRules) {
|
||||
// Reset regex state for reuse (global flag maintains lastIndex)
|
||||
regex.lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(lineText)) !== null) {
|
||||
const strStart = match.index;
|
||||
const strEnd = strStart + match[0].length;
|
||||
|
||||
// Map string indices to cell columns
|
||||
const cellStartCol = cellMap[strStart] ?? strStart;
|
||||
const cellEndCol = cellMap[strEnd] ?? strEnd;
|
||||
const cellWidth = cellEndCol - cellStartCol;
|
||||
|
||||
// Skip if width is 0 or negative (shouldn't happen, but be safe)
|
||||
if (cellWidth <= 0) continue;
|
||||
|
||||
// Calculate offset relative to the absolute cursor position
|
||||
// offset = targetLineAbs - (baseY + cursorY)
|
||||
const offset = lineY - cursorAbsoluteY;
|
||||
const marker = this.term.registerMarker(offset);
|
||||
|
||||
if (marker) {
|
||||
const deco = this.term.registerDecoration({
|
||||
marker,
|
||||
x: cellStartCol,
|
||||
width: cellWidth,
|
||||
foregroundColor: color,
|
||||
});
|
||||
|
||||
if (deco) {
|
||||
this.decorations.push({ decoration: deco, marker });
|
||||
} else {
|
||||
// If decoration failed, cleanup marker
|
||||
marker.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (text: string) => highlightKeywords(text, compiledRules);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ export type TerminalSessionStartersContext = {
|
||||
disposeExitRef: RefObject<(() => void) | null>;
|
||||
fitAddonRef: RefObject<FitAddon | null>;
|
||||
serializeAddonRef: RefObject<SerializeAddon | null>;
|
||||
highlightProcessorRef: RefObject<(text: string) => string>;
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
|
||||
updateStatus: (next: TerminalSession["status"]) => void;
|
||||
@@ -133,7 +132,7 @@ const attachSessionToTerminal = (
|
||||
// Replace \n that is not preceded by \r with \r\n
|
||||
data = data.replace(/(?<!\r)\n/g, "\r\n");
|
||||
}
|
||||
term.write(ctx.highlightProcessorRef.current(data));
|
||||
term.write(data);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
opts?.onConnected?.();
|
||||
@@ -556,7 +555,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
term.write(ctx.highlightProcessorRef.current(chunk));
|
||||
term.write(chunk);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getTerminalPassthroughActions,
|
||||
} from "../../../application/state/useGlobalHotkeys";
|
||||
import { fontStore } from "../../../application/state/fontStore";
|
||||
import { KeywordHighlighter } from "../keywordHighlight";
|
||||
import {
|
||||
XTERM_PERFORMANCE_CONFIG,
|
||||
type XTermPlatform,
|
||||
@@ -41,6 +42,7 @@ export type XTermRuntime = {
|
||||
dispose: () => void;
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
};
|
||||
|
||||
export type CreateXTermRuntimeContext = {
|
||||
@@ -111,9 +113,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
? (navigator as { deviceMemory?: number }).deviceMemory
|
||||
: undefined;
|
||||
|
||||
const settings = ctx.terminalSettingsRef.current;
|
||||
const rendererType = settings?.rendererType ?? "auto";
|
||||
|
||||
const performanceConfig = resolveXTermPerformanceConfig({
|
||||
platform,
|
||||
deviceMemoryGb,
|
||||
rendererType,
|
||||
});
|
||||
|
||||
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
|
||||
@@ -123,11 +129,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
|
||||
|
||||
const settings = ctx.terminalSettingsRef.current;
|
||||
const cursorStyle = settings?.cursorShape ?? "block";
|
||||
const cursorBlink = settings?.cursorBlink ?? true;
|
||||
const scrollback = settings?.scrollback ?? 10000;
|
||||
const fontLigatures = settings?.fontLigatures ?? true;
|
||||
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
|
||||
const fontWeight = settings?.fontWeight ?? 400;
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
@@ -136,6 +140,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const scrollOnUserInput = settings?.scrollOnInput ?? true;
|
||||
const altIsMeta = settings?.altAsMeta ?? false;
|
||||
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
|
||||
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
|
||||
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
|
||||
|
||||
const resolvedFontWeightBold = (() => {
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
|
||||
})();
|
||||
|
||||
const term = new XTerm({
|
||||
...performanceConfig.options,
|
||||
@@ -153,7 +167,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
| 900
|
||||
| "normal"
|
||||
| "bold",
|
||||
fontWeightBold: fontWeightBold as
|
||||
fontWeightBold: resolvedFontWeightBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
@@ -169,7 +183,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
cursorStyle,
|
||||
cursorBlink,
|
||||
scrollback,
|
||||
allowProposedApi: fontLigatures,
|
||||
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors,
|
||||
minimumContrastRatio,
|
||||
scrollOnUserInput,
|
||||
@@ -534,13 +549,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}, resizeDebounceMs);
|
||||
});
|
||||
|
||||
const keywordHighlighter = new KeywordHighlighter(term);
|
||||
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
|
||||
|
||||
return {
|
||||
term,
|
||||
fitAddon,
|
||||
serializeAddon,
|
||||
searchAddon,
|
||||
keywordHighlighter,
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
keywordHighlighter.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -42,6 +42,8 @@ export function Combobox({
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
// Track if user is actively searching (typed something after opening)
|
||||
const [isSearching, setIsSearching] = React.useState(false)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// Sync input value with external value when not focused
|
||||
@@ -49,11 +51,13 @@ export function Combobox({
|
||||
if (!open) {
|
||||
const selected = options.find((opt) => opt.value === value)
|
||||
setInputValue(selected?.label || value || "")
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [value, options, open])
|
||||
|
||||
// Show all options when dropdown is open but user hasn't started searching
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!inputValue.trim()) return options
|
||||
if (!isSearching || !inputValue.trim()) return options
|
||||
const lower = inputValue.toLowerCase()
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
@@ -61,13 +65,13 @@ export function Combobox({
|
||||
opt.value.toLowerCase().includes(lower) ||
|
||||
opt.sublabel?.toLowerCase().includes(lower)
|
||||
)
|
||||
}, [options, inputValue])
|
||||
}, [options, inputValue, isSearching])
|
||||
|
||||
const showCreateOption = React.useMemo(() => {
|
||||
if (!allowCreate || !inputValue.trim()) return false
|
||||
if (!allowCreate || !inputValue.trim() || !isSearching) return false
|
||||
const lower = inputValue.toLowerCase().trim()
|
||||
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
|
||||
}, [allowCreate, inputValue, options])
|
||||
}, [allowCreate, inputValue, options, isSearching])
|
||||
|
||||
const handleSelect = (optValue: string) => {
|
||||
onValueChange?.(optValue)
|
||||
@@ -87,6 +91,7 @@ export function Combobox({
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
setIsSearching(true)
|
||||
if (!open) setOpen(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,42 @@ const getContextMenuPortalEl = () => {
|
||||
zIndex: "2147483647", // max safe z-index to avoid being covered
|
||||
pointerEvents: "none",
|
||||
});
|
||||
|
||||
// Intercept aria-hidden attribute to prevent it from being set when menu is open
|
||||
// This avoids "Blocked aria-hidden on an element because its descendant retained focus" warnings
|
||||
let ariaHiddenValue: string | null = null;
|
||||
Object.defineProperty(portal, "ariaHidden", {
|
||||
get() {
|
||||
return ariaHiddenValue;
|
||||
},
|
||||
set(value: string | null) {
|
||||
// Block aria-hidden="true" when there are children (menu is open)
|
||||
if (value === "true" && portal && portal.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
ariaHiddenValue = value;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Also override setAttribute for aria-hidden
|
||||
const originalSetAttribute = portal.setAttribute.bind(portal);
|
||||
portal.setAttribute = function (name: string, value: string) {
|
||||
if (name === "aria-hidden" && value === "true" && portal && portal.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
originalSetAttribute(name, value);
|
||||
};
|
||||
|
||||
// Override removeAttribute to sync our internal state
|
||||
const originalRemoveAttribute = portal.removeAttribute.bind(portal);
|
||||
portal.removeAttribute = function (name: string) {
|
||||
if (name === "aria-hidden") {
|
||||
ariaHiddenValue = null;
|
||||
}
|
||||
originalRemoveAttribute(name);
|
||||
};
|
||||
|
||||
document.body.appendChild(portal);
|
||||
}
|
||||
return portal;
|
||||
|
||||
@@ -44,6 +44,7 @@ const DialogContent = React.forwardRef<
|
||||
className
|
||||
)}
|
||||
style={{ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15)' }}
|
||||
aria-describedby={undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user