Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5930d1601a | ||
|
|
df3d507e2b | ||
|
|
f8c7a9081b | ||
|
|
d8cfb0f1d9 | ||
|
|
269d790f28 | ||
|
|
0f12eab680 | ||
|
|
139fa43c43 | ||
|
|
eb30e6580e | ||
|
|
104a0c73d2 | ||
|
|
fc16739e99 | ||
|
|
dd386f218f | ||
|
|
254558771c | ||
|
|
9c9d01f372 | ||
|
|
a75b981630 | ||
|
|
2b706b7b4e | ||
|
|
8276f63c65 |
@@ -22,16 +22,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="macOS ARM64 をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="macOS Intel をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Windows をダウンロード">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -269,11 +261,13 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
### ダウンロード
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ダウンロード |
|
||||
|------------------|----------------|--------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ステータス |
|
||||
|------------------|----------------|------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
|
||||
| **macOS** | Intel | ✅ サポート |
|
||||
| **Windows** | x64 | ✅ サポート |
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -22,16 +22,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="Download macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="Download macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/Download-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Download Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Download%20Latest&color=success" alt="Download Latest Release">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -269,11 +261,13 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
|
||||
### Download
|
||||
|
||||
| Platform | Architecture | Download |
|
||||
|----------|--------------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
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 |
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
|
||||
@@ -22,16 +22,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="下载 macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="下载 macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/下载-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="下载 Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -269,11 +261,13 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
### 下载
|
||||
|
||||
| 平台 | 架构 | 下载链接 |
|
||||
|------|------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
|
||||
|
||||
| 平台 | 架构 | 状态 |
|
||||
|------|------|------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
|
||||
| **macOS** | Intel | ✅ 支持 |
|
||||
| **Windows** | x64 | ✅ 支持 |
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
|
||||
@@ -398,11 +398,13 @@ const en: Messages = {
|
||||
'sftp.itemsCount': '{count} items',
|
||||
'sftp.selectedCount': '{count} selected',
|
||||
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
|
||||
'sftp.showHiddenPaths': 'Hidden paths',
|
||||
'sftp.task.waiting': 'Waiting...',
|
||||
'sftp.status.loading': 'Loading...',
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goHome': 'Go to home',
|
||||
'sftp.folderName': 'Folder name',
|
||||
'sftp.folderName.placeholder': 'Enter folder name',
|
||||
'sftp.prompt.newFolderName': 'New folder name?',
|
||||
|
||||
@@ -272,11 +272,13 @@ const zhCN: Messages = {
|
||||
'sftp.itemsCount': '{count} 个项目',
|
||||
'sftp.selectedCount': '已选 {count} 个',
|
||||
'sftp.path.doubleClickToEdit': '双击编辑路径',
|
||||
'sftp.showHiddenPaths': '隐藏的路径',
|
||||
'sftp.task.waiting': '等待中...',
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
'sftp.prompt.newFolderName': '新建文件夹名称?',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getActiveRuleIds,
|
||||
startPortForward,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
|
||||
|
||||
@@ -78,25 +79,32 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
}, []);
|
||||
|
||||
// Load rules from storage on mount
|
||||
// Load rules from storage on mount and sync with backend
|
||||
useEffect(() => {
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
const loadAndSync = async () => {
|
||||
// First, sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAndSync();
|
||||
}, []);
|
||||
|
||||
// Persist rules to storage whenever they change
|
||||
|
||||
@@ -41,6 +41,11 @@ const getFileExtension = (name: string): string => {
|
||||
return ext || "file";
|
||||
};
|
||||
|
||||
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
|
||||
const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
|
||||
};
|
||||
|
||||
// Check if path is Windows-style
|
||||
const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
|
||||
|
||||
@@ -370,6 +375,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
sizeFormatted: f.size,
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
}));
|
||||
},
|
||||
[getMockLocalFiles],
|
||||
@@ -387,6 +393,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
sizeFormatted: f.size,
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
@@ -1338,7 +1345,8 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.type === "directory") {
|
||||
// Navigate into directories, or symlinks that point to directories
|
||||
if (isNavigableDirectory(entry)) {
|
||||
const newPath = joinPath(pane.connection.currentPath, entry.name);
|
||||
await navigateTo(side, newPath);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChevronRight,
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Key,
|
||||
Loader2,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
@@ -51,7 +53,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
// Comprehensive file icon helper
|
||||
const getFileIcon = (fileName: string, isDirectory: boolean) => {
|
||||
const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
|
||||
if (isDirectory)
|
||||
return (
|
||||
<Folder
|
||||
@@ -62,6 +64,11 @@ const getFileIcon = (fileName: string, isDirectory: boolean) => {
|
||||
/>
|
||||
);
|
||||
|
||||
// For symlink files (not directories), show a special symlink icon
|
||||
if (isSymlink) {
|
||||
return <ExternalLink size={18} className="text-cyan-500" />;
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
const iconClass = "text-muted-foreground";
|
||||
|
||||
@@ -324,6 +331,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editingPathValue, setEditingPathValue] = useState("");
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Breadcrumb truncation constant
|
||||
const MAX_VISIBLE_BREADCRUMB_PARTS = 4;
|
||||
|
||||
const isWindowsPath = useCallback((path: string): boolean => {
|
||||
return /^[A-Za-z]:/.test(path);
|
||||
@@ -871,9 +881,11 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
return [...files].sort((a, b) => {
|
||||
// Directories always first
|
||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||
// Directories and symlinks pointing to directories come first
|
||||
const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory");
|
||||
const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory");
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
@@ -974,6 +986,35 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbs = getBreadcrumbs(currentPath);
|
||||
|
||||
// Compute visible/hidden breadcrumbs for truncation (always truncate, no expansion)
|
||||
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } = useMemo(() => {
|
||||
if (breadcrumbs.length <= MAX_VISIBLE_BREADCRUMB_PARTS) {
|
||||
return {
|
||||
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
|
||||
needsBreadcrumbTruncation: false
|
||||
};
|
||||
}
|
||||
|
||||
// Show first part + ellipsis + last (MAX_VISIBLE_BREADCRUMB_PARTS - 1) parts
|
||||
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
|
||||
const lastPartsCount = MAX_VISIBLE_BREADCRUMB_PARTS - 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]);
|
||||
|
||||
const handleFileClick = (
|
||||
file: RemoteFile,
|
||||
@@ -1037,7 +1078,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
};
|
||||
|
||||
const handleFileDoubleClick = (file: RemoteFile) => {
|
||||
if (file.type === "directory") {
|
||||
// Navigate into directories, or symlinks that point to directories
|
||||
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
|
||||
handleNavigate(joinPath(currentPath, file.name));
|
||||
} else {
|
||||
handleDownload(file);
|
||||
@@ -1149,32 +1191,55 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
<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={t("sftp.path.doubleClickToEdit")}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1"
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={() => setCurrentPath(getRootPath(currentPath))}
|
||||
>
|
||||
{isLocalSession && isWindowsPath(currentPath)
|
||||
? getWindowsDrive(currentPath) ?? "C:"
|
||||
: "/"}
|
||||
</button>
|
||||
{breadcrumbs.map((part, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground truncate px-1"
|
||||
onClick={() =>
|
||||
setCurrentPath(breadcrumbPathAt(breadcrumbs, idx))
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{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={() =>
|
||||
setCurrentPath(breadcrumbPathAt(breadcrumbs, originalIndex))
|
||||
}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1304,7 +1369,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="divide-y divide-border/30">
|
||||
{sortedFiles.map((file, idx) => (
|
||||
{sortedFiles.map((file, idx) => {
|
||||
// Check if this entry is navigable like a directory
|
||||
const isNavigableDirectory = file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory");
|
||||
const isDownloadableFile = file.type === "file" || (file.type === "symlink" && file.linkTarget === "file");
|
||||
|
||||
return (
|
||||
<ContextMenu key={idx}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
@@ -1321,18 +1391,24 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getFileIcon(file.name, file.type === "directory")}
|
||||
{getFileIcon(file.name, isNavigableDirectory, file.type === "symlink" && !isNavigableDirectory)}
|
||||
</div>
|
||||
<span className="truncate font-medium">{file.name}</span>
|
||||
<span className={cn("truncate font-medium", file.type === "symlink" && "italic")}>
|
||||
{file.name}
|
||||
{file.type === "symlink" && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
{file.type === "symlink" && (
|
||||
<span className="text-xs text-muted-foreground shrink-0" aria-hidden="true">→</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{file.type === "directory" ? "--" : formatBytes(file.size)}
|
||||
{isNavigableDirectory ? "--" : formatBytes(file.size)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{formatDate(file.lastModified, resolvedLocale)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{file.type === "file" && (
|
||||
{isDownloadableFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1362,7 +1438,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{file.type === "directory" && (
|
||||
{(file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
@@ -1375,7 +1451,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{file.type === "file" && (
|
||||
{(file.type === "file" || (file.type === "symlink" && file.linkTarget === "file")) && (
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" /> {t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
@@ -1388,7 +1464,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Label } from "./ui/label";
|
||||
// Import extracted components
|
||||
import {
|
||||
ColumnWidths,
|
||||
isNavigableDirectory,
|
||||
SftpBreadcrumb,
|
||||
SftpConflictDialog,
|
||||
SftpFileRow,
|
||||
@@ -181,8 +182,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const currentValue = editingPathValue.trim().toLowerCase();
|
||||
const suggestions: { path: string; type: "folder" | "history" }[] = [];
|
||||
|
||||
// Include both directories and symlinks pointing to directories
|
||||
const folders = filteredFiles.filter(
|
||||
(f) => f.type === "directory" && f.name !== "..",
|
||||
(f) => isNavigableDirectory(f) && f.name !== "..",
|
||||
);
|
||||
folders.forEach((f) => {
|
||||
const fullPath =
|
||||
@@ -455,10 +457,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
.filter((f) => selectedNames.includes(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
isDirectory: f.type === "directory",
|
||||
isDirectory: isNavigableDirectory(f),
|
||||
side,
|
||||
}))
|
||||
: [{ name: entry.name, isDirectory: entry.type === "directory", 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);
|
||||
@@ -466,7 +468,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
const handleEntryDragOver = (entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (entry.type === "directory" && entry.name !== "..") {
|
||||
// Allow drag over for directories and symlinks pointing to directories
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(entry.name);
|
||||
@@ -475,7 +478,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
const handleEntryDrop = (entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (entry.type === "directory" && entry.name !== "..") {
|
||||
// Allow drop on directories and symlinks pointing to directories
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(null);
|
||||
@@ -851,7 +855,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
{entry.name !== ".." && (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onOpenEntry(entry)}>
|
||||
{entry.type === "directory"
|
||||
{isNavigableDirectory(entry)
|
||||
? t("sftp.context.open")
|
||||
: t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
@@ -867,7 +871,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file?.type === "directory" || false,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
onCopyToOtherPane(fileData);
|
||||
|
||||
@@ -2,17 +2,27 @@
|
||||
* SFTP Breadcrumb navigation component
|
||||
*/
|
||||
|
||||
import { ChevronRight,Home } from 'lucide-react';
|
||||
import React,{ memo } from 'react';
|
||||
import { ChevronRight, Home, MoreHorizontal } from 'lucide-react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface SftpBreadcrumbProps {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
onHome: () => void;
|
||||
/** Maximum number of visible path segments before truncation (default: 4) */
|
||||
maxVisibleParts?: number;
|
||||
}
|
||||
|
||||
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate, onHome }) => {
|
||||
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
path,
|
||||
onNavigate,
|
||||
onHome,
|
||||
maxVisibleParts = 4
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Handle both Windows (C:\path) and Unix (/path) style paths
|
||||
const isWindowsPath = /^[A-Za-z]:/.test(path);
|
||||
const separator = isWindowsPath ? /[\\/]/ : /\//;
|
||||
@@ -26,27 +36,73 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate,
|
||||
return '/' + parts.slice(0, index + 1).join('/');
|
||||
};
|
||||
|
||||
// Determine which parts to show (always truncate, no expansion)
|
||||
const { visibleParts, hiddenParts, needsTruncation } = useMemo(() => {
|
||||
if (parts.length <= maxVisibleParts) {
|
||||
return {
|
||||
visibleParts: parts.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenParts: [] as { part: string; originalIndex: number }[],
|
||||
needsTruncation: false
|
||||
};
|
||||
}
|
||||
|
||||
// Show first part + ellipsis + last (maxVisibleParts - 1) parts
|
||||
const firstPart = [{ part: parts[0], originalIndex: 0 }];
|
||||
const lastPartsCount = maxVisibleParts - 1;
|
||||
const lastParts = parts.slice(-lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: parts.length - lastPartsCount + idx
|
||||
}));
|
||||
const hidden = parts.slice(1, -lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: idx + 1
|
||||
}));
|
||||
|
||||
return {
|
||||
visibleParts: [...firstPart, ...lastParts],
|
||||
hiddenParts: hidden,
|
||||
needsTruncation: true
|
||||
};
|
||||
}, [parts, maxVisibleParts]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground overflow-x-auto scrollbar-none">
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden"
|
||||
title={path}
|
||||
>
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
|
||||
title="Go to home"
|
||||
title={t("sftp.goHome")}
|
||||
>
|
||||
<Home size={12} />
|
||||
</button>
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
{parts.map((part, idx) => {
|
||||
const partPath = buildPath(idx);
|
||||
const isLast = idx === parts.length - 1;
|
||||
{visibleParts.map(({ part, originalIndex }, displayIdx) => {
|
||||
const partPath = buildPath(originalIndex);
|
||||
const isLast = originalIndex === parts.length - 1;
|
||||
const showEllipsisBefore = needsTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={partPath}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<span
|
||||
className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.part).join(' > ')}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onNavigate(partPath)}
|
||||
className={cn(
|
||||
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px]",
|
||||
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
|
||||
isLast && "text-foreground font-medium"
|
||||
)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* SFTP File row component for file list
|
||||
*/
|
||||
|
||||
import { Folder } from 'lucide-react';
|
||||
import { Folder, Link } from 'lucide-react';
|
||||
import React,{ memo } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import { ColumnWidths,formatBytes,formatDate,getFileIcon } from './utils';
|
||||
import { ColumnWidths,formatBytes,formatDate,getFileIcon,isNavigableDirectory } from './utils';
|
||||
|
||||
interface SftpFileRowProps {
|
||||
entry: SftpFileEntry;
|
||||
@@ -36,6 +36,9 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onDrop,
|
||||
}) => {
|
||||
const isParentDir = entry.name === '..';
|
||||
// A symlink pointing to a directory behaves like a directory (navigable, accepts drops)
|
||||
const isNavDir = isNavigableDirectory(entry);
|
||||
const isSymlinkToDirectory = entry.type === 'symlink' && entry.linkTarget === 'directory';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -50,25 +53,32 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
|
||||
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
|
||||
isDragOver && entry.type === 'directory' && "bg-primary/25 ring-1 ring-primary/50"
|
||||
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0",
|
||||
entry.type === 'directory' ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
|
||||
isNavDir ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
|
||||
)}>
|
||||
{entry.type === 'directory' ? <Folder size={14} /> : getFileIcon(entry)}
|
||||
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
|
||||
{/* Show link indicator for symlinks */}
|
||||
{entry.type === 'symlink' && (
|
||||
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic")}>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">{formatDate(entry.lastModified)}</span>
|
||||
<span className="text-xs text-muted-foreground truncate text-right">
|
||||
{entry.type === 'directory' ? '--' : formatBytes(entry.size)}
|
||||
{isNavDir ? '--' : formatBytes(entry.size)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate capitalize text-right">
|
||||
{entry.type === 'directory' ? 'folder' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
|
||||
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes,formatDate,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,type ColumnWidths,type SortField,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
@@ -73,6 +74,11 @@ export const formatSpeed = (bytesPerSecond: number): string => {
|
||||
*/
|
||||
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
|
||||
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
|
||||
|
||||
// For symlink files (not directories), show a special symlink icon
|
||||
if (entry.type === 'symlink' && entry.linkTarget !== 'directory') {
|
||||
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
|
||||
}
|
||||
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
@@ -173,3 +179,11 @@ export interface ColumnWidths {
|
||||
size: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entry is navigable like a directory
|
||||
* This includes regular directories and symlinks that point to directories
|
||||
*/
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface Host {
|
||||
tags: string[];
|
||||
os: 'linux' | 'windows' | 'macos';
|
||||
identityFileId?: string; // Reference to SSHKey
|
||||
protocol?: 'ssh' | 'telnet' | 'local'; // Default/primary protocol
|
||||
protocol?: 'ssh' | 'telnet' | 'local' | 'serial'; // Default/primary protocol
|
||||
password?: string;
|
||||
authMethod?: 'password' | 'key' | 'certificate';
|
||||
agentForwarding?: boolean;
|
||||
@@ -464,9 +464,10 @@ export interface TerminalSession {
|
||||
|
||||
export interface RemoteFile {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: string;
|
||||
lastModified: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
}
|
||||
|
||||
export type WorkspaceNode =
|
||||
@@ -505,6 +506,7 @@ export interface SftpFileEntry {
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
}
|
||||
|
||||
export interface SftpConnection {
|
||||
|
||||
@@ -9,6 +9,7 @@ const os = require("node:os");
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
@@ -27,19 +28,53 @@ async function listLocalDir(event, payload) {
|
||||
const entry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
// fs.promises.stat follows symlinks, so we get the target's stats
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// This is a symlink - mark it as such and record the target type
|
||||
type = "symlink";
|
||||
// stat follows symlinks, so stat.isDirectory() tells us if target is a directory
|
||||
linkTarget = stat.isDirectory() ? "directory" : "file";
|
||||
} else if (entry.isDirectory()) {
|
||||
type = "directory";
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory()
|
||||
? "directory"
|
||||
: entry.isSymbolicLink()
|
||||
? "symlink"
|
||||
: "file",
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${stat.size} bytes`,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`Could not stat ${entry.name}:`, err.message);
|
||||
// Handle broken symlinks - lstat doesn't follow symlinks
|
||||
if (err.code === 'ENOENT' || err.code === 'ELOOP') {
|
||||
const brokenEntry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, brokenEntry.name);
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
linkTarget: null, // Broken link - target unknown
|
||||
size: `${lstat.size} bytes`,
|
||||
lastModified: lstat.mtime.toISOString(),
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch (lstatErr) {
|
||||
console.warn(`Could not lstat ${brokenEntry.name}:`, lstatErr.message);
|
||||
}
|
||||
}
|
||||
console.warn(`Could not stat ${entries[i].name}:`, err.message);
|
||||
result[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,28 @@ async function listPortForwards() {
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active port forwards (cleanup on app quit)
|
||||
*/
|
||||
function stopAllPortForwards() {
|
||||
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
|
||||
for (const [tunnelId, tunnel] of portForwardingTunnels) {
|
||||
try {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
console.log(`[PortForward] Stopped tunnel ${tunnelId}`);
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
|
||||
}
|
||||
}
|
||||
portForwardingTunnels.clear();
|
||||
console.log('[PortForward] All tunnels stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for port forwarding operations
|
||||
*/
|
||||
@@ -329,4 +351,5 @@ module.exports = {
|
||||
stopPortForward,
|
||||
getPortForwardStatus,
|
||||
listPortForwards,
|
||||
stopAllPortForwards,
|
||||
};
|
||||
|
||||
@@ -385,18 +385,53 @@ async function openSftp(event, options) {
|
||||
|
||||
/**
|
||||
* List files in a directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
*/
|
||||
async function listSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const list = await client.list(payload.path || ".");
|
||||
return list.map((item) => ({
|
||||
name: item.name,
|
||||
type: item.type === "d" ? "directory" : "file",
|
||||
size: `${item.size} bytes`,
|
||||
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
|
||||
const basePath = payload.path || ".";
|
||||
|
||||
// Process items and resolve symlinks
|
||||
const results = await Promise.all(list.map(async (item) => {
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
if (item.type === "d") {
|
||||
type = "directory";
|
||||
} else if (item.type === "l") {
|
||||
// This is a symlink - try to resolve its target type
|
||||
type = "symlink";
|
||||
try {
|
||||
// Use path.posix.join to properly construct the path and avoid double slashes
|
||||
const fullPath = path.posix.join(basePath === "." ? "/" : basePath, item.name);
|
||||
const stat = await client.stat(fullPath);
|
||||
// stat follows symlinks, so we get the target's type
|
||||
if (stat.isDirectory) {
|
||||
linkTarget = "directory";
|
||||
} else {
|
||||
linkTarget = "file";
|
||||
}
|
||||
} catch (err) {
|
||||
// If we can't stat the symlink target (broken link), keep it as symlink
|
||||
console.warn(`Could not resolve symlink target for ${item.name}:`, err.message);
|
||||
}
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${item.size} bytes`,
|
||||
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
|
||||
};
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -539,13 +539,18 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions before quitting to prevent node-pty assertion errors
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
console.warn("Error during terminal cleanup:", err);
|
||||
}
|
||||
try {
|
||||
portForwardingBridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
|
||||
@@ -35,6 +35,75 @@ export const getActiveRuleIds = (): string[] => {
|
||||
.map(([ruleId]) => ruleId);
|
||||
};
|
||||
|
||||
// Tunnel ID prefix and UUID regex pattern for parsing
|
||||
const TUNNEL_ID_PREFIX = 'pf-';
|
||||
// UUID format: 8-4-4-4-12 hexadecimal characters
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Parse rule ID from tunnel ID
|
||||
* Tunnel ID format is "pf-{ruleId}-{timestamp}" where ruleId is a UUID
|
||||
*/
|
||||
const parseRuleIdFromTunnelId = (tunnelId: string): string | null => {
|
||||
if (!tunnelId.startsWith(TUNNEL_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove prefix and split remaining parts
|
||||
const withoutPrefix = tunnelId.slice(TUNNEL_ID_PREFIX.length);
|
||||
const parts = withoutPrefix.split('-');
|
||||
|
||||
// UUID has 5 parts (8-4-4-4-12), so we need at least 6 parts (5 UUID + timestamp)
|
||||
if (parts.length < 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reconstruct the UUID from first 5 parts
|
||||
const ruleId = parts.slice(0, 5).join('-');
|
||||
|
||||
// Validate it's a proper UUID format
|
||||
if (!UUID_REGEX.test(ruleId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ruleId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync active connections with backend
|
||||
* Called on app startup to restore state of tunnels that may still be running
|
||||
* This updates the local activeConnections map to match the backend state.
|
||||
*/
|
||||
export const syncWithBackend = async (): Promise<void> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
if (!bridge?.listPortForwards) {
|
||||
logger.warn('[PortForwardingService] Backend not available for sync');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeTunnels = await bridge.listPortForwards();
|
||||
logger.info(`[PortForwardingService] Backend reports ${activeTunnels.length} active tunnels`);
|
||||
|
||||
for (const tunnel of activeTunnels) {
|
||||
const ruleId = parseRuleIdFromTunnelId(tunnel.tunnelId);
|
||||
if (ruleId) {
|
||||
// Update local connection tracking
|
||||
activeConnections.set(ruleId, {
|
||||
ruleId,
|
||||
tunnelId: tunnel.tunnelId,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
logger.info(`[PortForwardingService] Synced active tunnel for rule ${ruleId}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[PortForwardingService] Failed to sync with backend:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
*/
|
||||
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -1002,7 +1002,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1746,6 +1745,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1767,6 +1767,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1783,6 +1784,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1797,6 +1799,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5620,7 +5623,6 @@
|
||||
"integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
@@ -5650,7 +5652,6 @@
|
||||
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
@@ -5929,8 +5930,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -5952,7 +5952,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6012,7 +6011,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6380,7 +6378,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7105,7 +7102,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7346,7 +7344,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -7664,6 +7661,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7684,6 +7682,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7908,7 +7907,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10547,7 +10545,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10606,6 +10603,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10623,6 +10621,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10730,7 +10729,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10740,7 +10738,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11598,6 +11595,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11661,6 +11659,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11675,6 +11674,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -11823,7 +11823,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12026,7 +12025,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12365,7 +12363,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user