Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5317a4b81b | ||
|
|
2574d6d5e4 | ||
|
|
f04b1220ed | ||
|
|
ce4d156c2c | ||
|
|
ca46c9c924 | ||
|
|
f0d2c5c60d | ||
|
|
6cdf33a29d | ||
|
|
9b0b7c0eb7 | ||
|
|
5954359995 | ||
|
|
044165319e |
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
@@ -2,6 +2,11 @@ name: build-packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_release:
|
||||
description: "Publish GitHub Release after build"
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -74,7 +79,7 @@ jobs:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ coverage
|
||||
/release
|
||||
/out
|
||||
*.asar
|
||||
/public/monaco
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -375,6 +375,8 @@ const en: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
|
||||
@@ -249,6 +249,8 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
|
||||
@@ -618,15 +618,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
return getMockLocalFiles(path);
|
||||
}
|
||||
|
||||
return rawFiles.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size: parseInt(f.size) || 0,
|
||||
sizeFormatted: f.size,
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
}));
|
||||
return rawFiles.map((f) => {
|
||||
const size = parseInt(f.size) || 0;
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size,
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
[getMockLocalFiles],
|
||||
);
|
||||
@@ -636,15 +639,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path);
|
||||
if (!rawFiles) return [];
|
||||
|
||||
return rawFiles.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size: parseInt(f.size) || 0,
|
||||
sizeFormatted: f.size,
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
}));
|
||||
return rawFiles.map((f) => {
|
||||
const size = parseInt(f.size) || 0;
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size,
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -243,6 +243,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
|
||||
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Inline search/filter bar state
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
const pathDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState(0);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
@@ -1003,161 +1007,176 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onDragLeave={handlePaneDragLeave}
|
||||
onDrop={handlePaneDrop}
|
||||
>
|
||||
{/* Header - compact version - only show when showHeader is true */}
|
||||
{showHeader && (
|
||||
<div className="h-8 px-3 border-b border-border/60 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium">
|
||||
{pane.connection.isLocal ? (
|
||||
<Monitor size={12} />
|
||||
) : (
|
||||
<HardDrive size={12} />
|
||||
)}
|
||||
<span>{pane.connection.hostLabel}</span>
|
||||
{(pane.connection.status === "connecting" || pane.reconnecting) && (
|
||||
<Loader2 size={10} className="animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{pane.reconnecting && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Reconnecting...
|
||||
</span>
|
||||
)}
|
||||
{pane.connection.status === "error" && !pane.reconnecting && (
|
||||
<AlertCircle size={10} className="text-destructive" />
|
||||
|
||||
|
||||
{/* 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 items-center gap-1 ml-auto">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={pane.filter}
|
||||
onChange={(e) =>
|
||||
startTransition(() => onSetFilter(e.target.value))
|
||||
}
|
||||
placeholder="Filter..."
|
||||
className="h-6 w-28 pl-6 pr-5 text-[10px] bg-secondary/40"
|
||||
/>
|
||||
{pane.filter && (
|
||||
<button
|
||||
onClick={() => startTransition(() => onSetFilter(""))}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onRefresh}
|
||||
title={t("common.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
size={12}
|
||||
className={
|
||||
pane.loading || pane.reconnecting ? "animate-spin" : ""
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Toolbar - compact - only show when showHeader is true */}
|
||||
{showHeader && (
|
||||
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onNavigateUp}
|
||||
title={t("sftp.goUp")}
|
||||
className="h-6 w-6"
|
||||
onClick={() => setShowNewFolderDialog(true)}
|
||||
title={t("sftp.newFolder")}
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
<FolderPlus 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>
|
||||
|
||||
{/* 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)
|
||||
{/* 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);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setShowNewFolderDialog(true)}
|
||||
>
|
||||
<FolderPlus size={12} className="mr-1" /> {t("sftp.newFolder")}
|
||||
</Button>
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
loader.config({ paths: { vs: './node_modules/monaco-editor/min/vs' } });
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
@@ -93,6 +96,21 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Reset content when file changes
|
||||
useEffect(() => {
|
||||
setContent(initialContent);
|
||||
@@ -159,6 +177,7 @@ 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],
|
||||
@@ -238,9 +257,9 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme="vs-dark"
|
||||
theme={monacoTheme}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full bg-[#1e1e1e]">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"scripts": {
|
||||
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
|
||||
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
|
||||
"prebuild": "node scripts/copy-monaco.cjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs",
|
||||
|
||||
16
scripts/copy-monaco.cjs
Normal file
16
scripts/copy-monaco.cjs
Normal file
@@ -0,0 +1,16 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const source = path.join(repoRoot, 'node_modules', 'monaco-editor', 'min', 'vs');
|
||||
const target = path.join(repoRoot, 'public', 'monaco', 'vs');
|
||||
|
||||
if (!fs.existsSync(source)) {
|
||||
console.error('[copy-monaco] Source not found:', source);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.cpSync(source, target, { recursive: true });
|
||||
console.log('[copy-monaco] Copied Monaco VS assets to', target);
|
||||
Reference in New Issue
Block a user