Compare commits

..

10 Commits

Author SHA1 Message Date
LAPTOP-O016UC3M\Qi Chen
5317a4b81b Removes unnecessary whitespace in state initialization
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Cleans up formatting in the state initialization expression for improved code consistency. No functional changes introduced.
2026-01-08 11:15:16 +08:00
LAPTOP-O016UC3M\Qi Chen
2574d6d5e4 Syncs editor theme with app by observing HTML class
Replaces use of global settings state for theme with a MutationObserver
that tracks the presence of the 'dark' class on the root HTML element.
Ensures the editor theme stays consistent with the actual app theme,
even if changed via side effects outside React state.
2026-01-08 11:15:11 +08:00
LAPTOP-O016UC3M\Qi Chen
f04b1220ed Syncs editor theme with user settings
Updates the editor to use the app's current theme preference,
ensuring consistency with user-selected light or dark modes.
Also improves the loading UI to better match the surrounding style.
2026-01-08 11:09:41 +08:00
LAPTOP-O016UC3M\Qi Chen
ce4d156c2c Remove trailing whitespace for code style consistency
Cleans up unnecessary trailing whitespace to maintain consistent code formatting and improve readability. No functional changes introduced.
2026-01-08 11:05:46 +08:00
LAPTOP-O016UC3M\Qi Chen
ca46c9c924 Adds toggleable filter bar to SFTP pane toolbar
Introduces a dedicated, toggleable filter bar for searching SFTP files, improving discoverability and usability over the previous inline filter input. Updates translations to support the new filter UI in English and Chinese.

Enhances user experience by making filtering more accessible and visually distinct from other toolbar actions.
2026-01-08 11:05:41 +08:00
陈大猫
f0d2c5c60d Merge pull request #48 from binaricat/copilot/add-human-readable-file-size
Display human-readable file sizes in SftpView
2026-01-08 10:58:18 +08:00
copilot-swe-agent[bot]
6cdf33a29d Fix file sizes to display in human-readable format (KB, MB, GB) in SftpView
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 02:51:18 +00:00
copilot-swe-agent[bot]
9b0b7c0eb7 Initial plan 2026-01-08 02:46:44 +00:00
LAPTOP-O016UC3M\Qi Chen
5954359995 Allow manual build trigger to publish releases
Enables publishing a GitHub Release when the workflow is manually
triggered and the corresponding input is set, providing more
flexibility for release management beyond tag-based automation.
2026-01-08 10:40:00 +08:00
LAPTOP-O016UC3M\Qi Chen
044165319e Updates Monaco editor path handling for production builds
Configures the editor to load Monaco assets from a local directory in production, improving reliability and performance by avoiding CDN usage.
Adds prebuild script to copy Monaco files, and updates ignore rules to exclude copied assets from version control.
2026-01-08 10:34:49 +08:00
9 changed files with 236 additions and 165 deletions

View File

@@ -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
View File

@@ -22,6 +22,7 @@ coverage
/release
/out
*.asar
/public/monaco
# Editor directories and files
.vscode/*

View File

@@ -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',

View File

@@ -249,6 +249,8 @@ const zhCN: Messages = {
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',

View File

@@ -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,
};
});
},
[],
);

View File

@@ -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>
)}

View File

@@ -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>
}

View File

@@ -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
View 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);