From a35ec0bcabbf2826efa97663a1dd1092cfa729ef Mon Sep 17 00:00:00 2001 From: Joes Date: Tue, 7 Jul 2026 11:43:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20SFTP=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8=E5=A4=A7=E5=B9=85=E5=A2=9E=E5=BC=BA=EF=BC=9A=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E3=80=81=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交显著扩展了SFTP文件管理器功能和交互体验,包括: - 支持按名称、大小、权限、修改时间排序,目录优先,列头可点击切换升降序; - 工具栏与右键菜单重构,支持新建文件/文件夹、下载、重命名、移动、复制路径/名称、属性等操作; - 文件列表切换为ListBox,支持多选和双击进入目录; - 新增属性弹窗、剪贴板复制、对话框输入等交互; - ISftpService接口扩展,支持文件创建、重命名/移动、获取工作目录、关闭会话等; - 新增“tools.files”命令(Ctrl+Shift+F),主窗口自动管理SFTP面板绑定与销毁; - 单元测试覆盖所有新增功能; - 代码风格和注释优化,.gitignore补充FodyWeavers.xsd。 整体提升了易用性、可维护性和现代化体验。 --- .gitignore | 2 + .../ViewModels/FileBrowserViewModel.cs | 276 ++++++++++++++++-- .../ViewModels/MainWindowViewModel.cs | 64 +++- src/PulseTerm.App/Views/FileBrowserView.axaml | 174 +++++++---- .../Views/FileBrowserView.axaml.cs | 119 ++++++++ src/PulseTerm.App/Views/MainWindow.axaml | 1 + src/PulseTerm.App/Views/MenuBarView.axaml | 2 +- src/PulseTerm.Core/Sftp/ISftpService.cs | 15 + src/PulseTerm.Core/Sftp/SftpService.cs | 47 +++ src/PulseTerm.Core/Ssh/ISftpClientWrapper.cs | 1 + .../Ssh/SftpClientWrapper.cs | 6 + .../ViewModels/FileBrowserViewModelTests.cs | 231 ++++++++++++++- .../ViewModels/MainWindowViewModelTests.cs | 27 ++ .../Sftp/SftpServiceTests.cs | 53 ++++ 14 files changed, 925 insertions(+), 93 deletions(-) diff --git a/.gitignore b/.gitignore index 48aba5e0..280f0477 100644 --- a/.gitignore +++ b/.gitignore @@ -366,3 +366,5 @@ FodyWeavers.xsd .idea/ /obdata +.omc +.sisyphus diff --git a/src/PulseTerm.App/ViewModels/FileBrowserViewModel.cs b/src/PulseTerm.App/ViewModels/FileBrowserViewModel.cs index 2e0bec41..0d1f76d8 100644 --- a/src/PulseTerm.App/ViewModels/FileBrowserViewModel.cs +++ b/src/PulseTerm.App/ViewModels/FileBrowserViewModel.cs @@ -19,6 +19,8 @@ public class FileBrowserViewModel : ReactiveObject private bool _isLoading; private bool _isVisible; private string? _errorMessage; + private string _sortColumn = "name"; + private bool _sortDescending; public FileBrowserViewModel(ISftpService? sftpService, Guid sessionId) { @@ -32,13 +34,21 @@ public FileBrowserViewModel(ISftpService? sftpService, Guid sessionId) SelectedFiles = new ObservableCollection(); NavigateToCommand = ReactiveCommand.CreateFromTask(NavigateToAsync); + ActivateCommand = ReactiveCommand.CreateFromTask(ActivateAsync); GoUpCommand = ReactiveCommand.CreateFromTask(GoUpAsync); RefreshCommand = ReactiveCommand.CreateFromTask(RefreshAsync); + LoadInitialCommand = ReactiveCommand.CreateFromTask(LoadInitialAsync); UploadCommand = ReactiveCommand.CreateFromTask(UploadAsync); - DownloadCommand = ReactiveCommand.CreateFromTask(DownloadAsync); - DeleteCommand = ReactiveCommand.CreateFromTask(DeleteAsync); - CreateFolderCommand = ReactiveCommand.CreateFromTask(CreateFolderAsync); + NewFolderCommand = ReactiveCommand.CreateFromTask(NewFolderAsync); + NewFileCommand = ReactiveCommand.CreateFromTask(NewFileAsync); + DownloadItemCommand = ReactiveCommand.CreateFromTask(DownloadItemAsync); + RenameCommand = ReactiveCommand.CreateFromTask(RenameAsync); + MoveCommand = ReactiveCommand.CreateFromTask(MoveAsync); + CopyPathCommand = ReactiveCommand.CreateFromTask(CopyPathAsync); + CopyNameCommand = ReactiveCommand.CreateFromTask(CopyNameAsync); + PropertiesCommand = ReactiveCommand.CreateFromTask(ShowPropertiesAsync); ToggleVisibilityCommand = ReactiveCommand.Create(ToggleVisibility); + SortCommand = ReactiveCommand.Create(ToggleSort); } /// The SSH session this browser is rooted at. @@ -73,14 +83,54 @@ public string? ErrorMessage } public ReactiveCommand NavigateToCommand { get; } + + /// Row activation (double-click / Enter): descend into directories. + public ReactiveCommand ActivateCommand { get; } public ReactiveCommand GoUpCommand { get; } public ReactiveCommand RefreshCommand { get; } + + /// Loads the account's home directory (spec: land in ~, not filesystem root). + public ReactiveCommand LoadInitialCommand { get; } + /// Uploads OS-picked files into the current directory (toolbar + right-click). public ReactiveCommand UploadCommand { get; } - public ReactiveCommand DownloadCommand { get; } - public ReactiveCommand DeleteCommand { get; } - public ReactiveCommand CreateFolderCommand { get; } + + // Right-click context-menu actions (spec: file operations live in the SFTP context menu). + public ReactiveCommand NewFolderCommand { get; } + public ReactiveCommand NewFileCommand { get; } + public ReactiveCommand DownloadItemCommand { get; } + public ReactiveCommand RenameCommand { get; } + public ReactiveCommand MoveCommand { get; } + public ReactiveCommand CopyPathCommand { get; } + public ReactiveCommand CopyNameCommand { get; } + public ReactiveCommand PropertiesCommand { get; } public ReactiveCommand ToggleVisibilityCommand { get; } + /// Sorts by a column key ("name" | "size" | "permissions" | "modified"); clicking the + /// active column again flips the direction. + public ReactiveCommand SortCommand { get; } + + /// The column the list is currently ordered by. + public string SortColumn + { + get => _sortColumn; + private set => this.RaiseAndSetIfChanged(ref _sortColumn, value); + } + + /// Whether the current sort is descending. + public bool SortDescending + { + get => _sortDescending; + private set => this.RaiseAndSetIfChanged(ref _sortDescending, value); + } + + public string NameSortGlyph => GlyphFor("name"); + public string SizeSortGlyph => GlyphFor("size"); + public string PermissionsSortGlyph => GlyphFor("permissions"); + public string ModifiedSortGlyph => GlyphFor("modified"); + + private string GlyphFor(string column) => + SortColumn == column ? (SortDescending ? " ▼" : " ▲") : string.Empty; + public string[] PathSegments => CurrentPath.Split('/', StringSplitOptions.RemoveEmptyEntries); private async Task NavigateToAsync(string path, CancellationToken ct = default) @@ -99,11 +149,12 @@ private async Task NavigateToAsync(string path, CancellationToken ct = default) CurrentPath = path; var files = await _sftpService.ListDirectoryAsync(_sessionId, path, ct); + var items = files.Select(f => new RemoteFileInfoViewModel(f)); Files.Clear(); - foreach (var file in files) + foreach (var file in SortFiles(items)) { - Files.Add(new RemoteFileInfoViewModel(file)); + Files.Add(file); } } catch (Exception ex) @@ -116,6 +167,88 @@ private async Task NavigateToAsync(string path, CancellationToken ct = default) } } + /// Loads the SFTP account's home directory (its post-login working directory) rather + /// than the filesystem root, falling back to "/" if it can't be resolved. + private async Task LoadInitialAsync(CancellationToken ct = default) + { + var home = "/"; + if (_sftpService is not null) + { + try + { + var working = await _sftpService.GetWorkingDirectoryAsync(_sessionId, ct); + if (!string.IsNullOrWhiteSpace(working)) + home = working; + } + catch + { + // Resolving the home directory is best-effort; fall back to root. + } + } + + await NavigateToAsync(home, ct); + } + + /// Sets or flips the sort, then reorders the currently loaded rows in place. + private void ToggleSort(string column) + { + if (string.IsNullOrWhiteSpace(column)) + return; + + if (SortColumn == column) + { + SortDescending = !SortDescending; + } + else + { + SortColumn = column; + SortDescending = false; + } + + this.RaisePropertyChanged(nameof(NameSortGlyph)); + this.RaisePropertyChanged(nameof(SizeSortGlyph)); + this.RaisePropertyChanged(nameof(PermissionsSortGlyph)); + this.RaisePropertyChanged(nameof(ModifiedSortGlyph)); + + var sorted = SortFiles(Files.ToList()); + Files.Clear(); + foreach (var file in sorted) + { + Files.Add(file); + } + } + + /// Orders rows by the active column and direction, keeping directories grouped first + /// (a directory's size is meaningless, so mixing them into a size sort reads badly). + private IEnumerable SortFiles(IEnumerable items) + { + var dirsFirst = items.OrderByDescending(f => f.IsDirectory); + + return SortColumn switch + { + "size" => SortDescending + ? dirsFirst.ThenByDescending(f => f.SizeBytes) + : dirsFirst.ThenBy(f => f.SizeBytes), + "permissions" => SortDescending + ? dirsFirst.ThenByDescending(f => f.Permissions, StringComparer.Ordinal) + : dirsFirst.ThenBy(f => f.Permissions, StringComparer.Ordinal), + "modified" => SortDescending + ? dirsFirst.ThenByDescending(f => f.LastModified) + : dirsFirst.ThenBy(f => f.LastModified), + _ => SortDescending + ? dirsFirst.ThenByDescending(f => f.Name, StringComparer.OrdinalIgnoreCase) + : dirsFirst.ThenBy(f => f.Name, StringComparer.OrdinalIgnoreCase), + }; + } + + private async Task ActivateAsync(RemoteFileInfoViewModel? file, CancellationToken ct = default) + { + if (file is null || !file.IsDirectory) + return; + + await NavigateToAsync(file.FullPath, ct); + } + private async Task GoUpAsync(CancellationToken ct = default) { if (CurrentPath == "/") return; @@ -137,6 +270,16 @@ private async Task RefreshAsync(CancellationToken ct = default) /// Set by the view: asks where to save a download (arg = suggested file name). public Func>? PickSavePathForDownload { get; set; } + /// Set by the view: prompts for a line of text (title, initial value) → entered text or + /// null if cancelled. Used by new folder / new file / rename / move. + public Func>? PromptForText { get; set; } + + /// Set by the view: writes text to the OS clipboard (copy path / copy name). + public Func? CopyToClipboard { get; set; } + + /// Set by the view: shows a file's properties in a modal. + public Func? ShowFileProperties { get; set; } + /// The floating transfer toast fed by uploads/downloads started here (spec §9). public FileTransferViewModel? TransferSink { get; set; } @@ -156,21 +299,16 @@ private async Task UploadAsync(CancellationToken ct = default) await RefreshAsync(ct); } - private async Task DownloadAsync(CancellationToken ct = default) + private async Task DownloadItemAsync(RemoteFileInfoViewModel? file, CancellationToken ct = default) { - if (_sftpService is null || PickSavePathForDownload is null) + if (_sftpService is null || PickSavePathForDownload is null || file is null || file.IsDirectory) return; - var selected = SelectedFiles.FirstOrDefault(f => !f.IsDirectory); - if (selected is null) - return; - - var localPath = await PickSavePathForDownload(selected.Name); + var localPath = await PickSavePathForDownload(file.Name); if (string.IsNullOrEmpty(localPath)) return; - var remotePath = CurrentPath.TrimEnd('/') + "/" + selected.Name; - await RunTransferAsync(TransferType.Download, localPath, remotePath, ct); + await RunTransferAsync(TransferType.Download, localPath, file.FullPath, ct); } /// Runs one transfer end to end: registers it with the toast, streams progress @@ -212,23 +350,40 @@ private async Task RunTransferAsync(TransferType type, string localPath, string } } - private async Task DeleteAsync(CancellationToken ct = default) + private async Task NewFolderAsync(CancellationToken ct = default) { - if (_sftpService is null) - { + if (_sftpService is null || PromptForText is null) + return; + + var name = await PromptForText("新建文件夹", ""); + if (string.IsNullOrWhiteSpace(name)) return; - } try { ErrorMessage = null; - var toDelete = SelectedFiles.ToList(); + await _sftpService.CreateDirectoryAsync(_sessionId, CombinePath(CurrentPath, name.Trim()), ct); + await RefreshAsync(ct); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + } + } - foreach (var file in toDelete) - { - await _sftpService.DeleteAsync(_sessionId, file.FullPath, ct); - } + private async Task NewFileAsync(CancellationToken ct = default) + { + if (_sftpService is null || PromptForText is null) + return; + + var name = await PromptForText("新建文件", ""); + if (string.IsNullOrWhiteSpace(name)) + return; + try + { + ErrorMessage = null; + await _sftpService.CreateFileAsync(_sessionId, CombinePath(CurrentPath, name.Trim()), ct); await RefreshAsync(ct); } catch (Exception ex) @@ -237,18 +392,41 @@ private async Task DeleteAsync(CancellationToken ct = default) } } - private async Task CreateFolderAsync(CancellationToken ct = default) + private async Task RenameAsync(RemoteFileInfoViewModel? file, CancellationToken ct = default) { - if (_sftpService is null) - { + if (_sftpService is null || PromptForText is null || file is null) + return; + + var newName = await PromptForText("重命名", file.Name); + if (string.IsNullOrWhiteSpace(newName) || newName.Trim() == file.Name) return; + + try + { + ErrorMessage = null; + var target = CombinePath(ParentOf(file.FullPath), newName.Trim()); + await _sftpService.RenameAsync(_sessionId, file.FullPath, target, ct); + await RefreshAsync(ct); } + catch (Exception ex) + { + ErrorMessage = ex.Message; + } + } + + private async Task MoveAsync(RemoteFileInfoViewModel? file, CancellationToken ct = default) + { + if (_sftpService is null || PromptForText is null || file is null) + return; + + var destination = await PromptForText("移动到(目标完整路径)", file.FullPath); + if (string.IsNullOrWhiteSpace(destination) || destination.Trim() == file.FullPath) + return; try { ErrorMessage = null; - var newFolderPath = CurrentPath.TrimEnd('/') + "/New Folder"; - await _sftpService.CreateDirectoryAsync(_sessionId, newFolderPath, ct); + await _sftpService.RenameAsync(_sessionId, file.FullPath, destination.Trim(), ct); await RefreshAsync(ct); } catch (Exception ex) @@ -257,6 +435,42 @@ private async Task CreateFolderAsync(CancellationToken ct = default) } } + private async Task CopyPathAsync(RemoteFileInfoViewModel? file, CancellationToken ct = default) + { + if (CopyToClipboard is null || file is null) + return; + + await CopyToClipboard(file.FullPath); + } + + private async Task CopyNameAsync(RemoteFileInfoViewModel? file, CancellationToken ct = default) + { + if (CopyToClipboard is null || file is null) + return; + + await CopyToClipboard(file.Name); + } + + private async Task ShowPropertiesAsync(RemoteFileInfoViewModel? file, CancellationToken ct = default) + { + if (ShowFileProperties is null || file is null) + return; + + await ShowFileProperties(file); + } + + /// Joins a directory and a leaf name into a Unix-style remote path. + private static string CombinePath(string directory, string name) => + directory == "/" ? "/" + name : directory.TrimEnd('/') + "/" + name; + + /// The parent directory of a Unix-style remote path. + private static string ParentOf(string path) + { + var trimmed = path.TrimEnd('/'); + var lastSlash = trimmed.LastIndexOf('/'); + return lastSlash <= 0 ? "/" : trimmed[..lastSlash]; + } + private void ToggleVisibility() { IsVisible = !IsVisible; diff --git a/src/PulseTerm.App/ViewModels/MainWindowViewModel.cs b/src/PulseTerm.App/ViewModels/MainWindowViewModel.cs index ae1b1450..5e29c01a 100644 --- a/src/PulseTerm.App/ViewModels/MainWindowViewModel.cs +++ b/src/PulseTerm.App/ViewModels/MainWindowViewModel.cs @@ -132,6 +132,9 @@ private void RegisterCommands() Commands.Register(new CommandDescriptor("tools.tunnel", "隧道管理", "工具", ToggleTunnelPanel, CanExecute: () => ActiveTerminalTab is not null, Shortcut: "Ctrl+Shift+T", Icon: "Icon.route")); + Commands.Register(new CommandDescriptor("tools.files", "SFTP 文件管理器", "工具", + ToggleFileBrowser, + CanExecute: () => ActiveTerminalTab is not null, Shortcut: "Ctrl+Shift+F", Icon: "Icon.folder")); Commands.Register(new CommandDescriptor("edit.clear", "清屏", "编辑", () => ActiveTerminalTab?.TerminalEmulator.WriteInput(new byte[] { 0x0C }), CanExecute: () => ActiveTerminalTab?.ConnectionStatus == SessionStatus.Connected)); @@ -157,11 +160,44 @@ private void RebindFileBrowser() if (FileBrowser.SessionId == tab.SessionId) return; + // Carry the open/closed state across the rebind so switching to (or connecting) a tab + // never silently hides a panel the user had opened. + var wasVisible = FileBrowser.IsVisible; FileBrowser = new FileBrowserViewModel(_sftpService, tab.SessionId) { TransferSink = FileTransfer, + IsVisible = wasVisible, }; - FileBrowser.RefreshCommand.Execute().Subscribe(_ => { }, _ => { }); + if (wasVisible) + FileBrowser.RefreshCommand.Execute().Subscribe(_ => { }, _ => { }); + } + + /// Toggles the SFTP panel for the active session (#22, spec §9). Opening it binds the + /// browser to the current session (if not already) and loads the initial listing. + public void ToggleFileBrowser() + { + // Ensure the browser points at the active tab's (now-connected) session before showing it. + // The active-tab subscription can't do this on its own because the session id is assigned + // after the tab is activated, so we rebind on demand here as well. + RebindFileBrowser(); + + FileBrowser.IsVisible = !FileBrowser.IsVisible; + if (FileBrowser.IsVisible && FileBrowser.SessionId != Guid.Empty) + FileBrowser.RefreshCommand.Execute().Subscribe(_ => { }, _ => { }); + } + + /// Called once a session finishes connecting: binds the file browser to it and shows + /// the listing. Per spec §9 the file area is part of the session view (visible by default, + /// collapsible), so a fresh connection surfaces its files without the user hunting for a toggle. + private void ShowFileBrowserForActiveSession() + { + RebindFileBrowser(); + + if (_sftpService is null || FileBrowser.SessionId == Guid.Empty) + return; + + FileBrowser.IsVisible = true; + FileBrowser.LoadInitialCommand.Execute().Subscribe(_ => { }, _ => { }); } /// Raised when the user asks for in-terminal search via menu/palette; the window /// forwards it to the active terminal view's search bar (§5.3). @@ -312,6 +348,11 @@ public async Task ConnectProfileAsync(SessionProfile profi terminalTab.AttachTransport(shellStream); terminalTab.Start(); terminalTab.ConnectionStatus = SessionStatus.Connected; + + // The session id only exists now, after the handshake — the active-tab subscription + // fired before it was assigned, so bind the SFTP browser here (and show + load it) or + // it stays bound to the empty placeholder and never loads a listing (#22). + ShowFileBrowserForActiveSession(); } catch { @@ -373,6 +414,9 @@ public async Task ReconnectTabAsync(TerminalTabViewModel tab, CancellationToken tab.Start(); tab.ConnectionStatus = SessionStatus.Connected; + // Reconnect mints a fresh session id; rebind the SFTP browser to it and reload (#22). + ShowFileBrowserForActiveSession(); + StatusBar.ResetUptime(); UpdateStatusBarForActiveTab(); LastConnectionError = null; @@ -556,9 +600,27 @@ private void OnDocumentClosed(TerminalDocument document) { TabBar.CloseTabCommand.Execute(tab).Subscribe(); } + CloseSftpForTab(tab); tab.Dispose(); } + /// Tears down the SFTP channel bound to a closing tab's session and, if the browser is + /// still showing that session, unbinds and hides it — closing the SSH tab must not leave a live, + /// operable SFTP panel behind (#22). + private void CloseSftpForTab(TerminalTabViewModel tab) + { + if (_sftpService is null || tab.SessionId == Guid.Empty) + return; + + var closedSessionId = tab.SessionId; + _ = _sftpService.CloseSessionAsync(closedSessionId); + + // The active-tab change from closing may have already rebound the browser to another + // session; only reset when it still points at the one we just closed. + if (FileBrowser.SessionId == closedSessionId) + FileBrowser = new FileBrowserViewModel(_sftpService, Guid.Empty); + } + public FileBrowserViewModel FileBrowser { get => _fileBrowser; diff --git a/src/PulseTerm.App/Views/FileBrowserView.axaml b/src/PulseTerm.App/Views/FileBrowserView.axaml index 353444de..62474809 100644 --- a/src/PulseTerm.App/Views/FileBrowserView.axaml +++ b/src/PulseTerm.App/Views/FileBrowserView.axaml @@ -19,14 +19,27 @@ + + + VerticalAlignment="Stretch" + MinHeight="120"> @@ -86,30 +99,6 @@ Data="{StaticResource Icon.corner-left-up}" Foreground="{DynamicResource PulseTextTertiary}" /> - - - @@ -131,38 +120,78 @@ BorderThickness="0,0,0,1" Padding="14,0"> - - - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + diff --git a/src/PulseTerm.App/Views/FileBrowserView.axaml.cs b/src/PulseTerm.App/Views/FileBrowserView.axaml.cs index 47ae0308..7bc5b886 100644 --- a/src/PulseTerm.App/Views/FileBrowserView.axaml.cs +++ b/src/PulseTerm.App/Views/FileBrowserView.axaml.cs @@ -2,7 +2,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Layout; using Avalonia.Markup.Xaml; using Avalonia.Platform.Storage; using PulseTerm.App.ViewModels; @@ -23,9 +27,26 @@ public FileBrowserView() vm.PickFilesForUpload = PickFilesAsync; vm.PickSavePathForDownload = PickSavePathAsync; + vm.PromptForText = PromptForTextAsync; + vm.CopyToClipboard = CopyToClipboardAsync; + vm.ShowFileProperties = ShowFilePropertiesAsync; }; } + /// Double-clicking a row descends into a directory (files are left to the toolbar + /// download action, which needs a save target). + private void OnFileDoubleTapped(object? sender, TappedEventArgs e) + { + if (DataContext is not FileBrowserViewModel vm) + return; + + var row = (e.Source as Control)?.DataContext as RemoteFileInfoViewModel; + if (row is null || !row.IsDirectory) + return; + + vm.ActivateCommand.Execute(row).Subscribe(_ => { }, _ => { }); + } + private async Task> PickFilesAsync() { var top = TopLevel.GetTopLevel(this); @@ -59,4 +80,102 @@ private async Task> PickFilesAsync() return file?.TryGetLocalPath(); } + + /// Modal single-line text prompt used by new folder / new file / rename / move. + /// Returns the entered text, or null if the user cancelled. + private async Task PromptForTextAsync(string title, string initialValue) + { + if (TopLevel.GetTopLevel(this) is not Window owner) + return null; + + var textBox = new TextBox { Text = initialValue, MinWidth = 340 }; + var okButton = new Button { Content = "确定", IsDefault = true, MinWidth = 72 }; + var cancelButton = new Button { Content = "取消", IsCancel = true, MinWidth = 72 }; + + string? result = null; + var dialog = new Window + { + Title = title, + CanResize = false, + ShowInTaskbar = false, + SizeToContent = SizeToContent.WidthAndHeight, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new StackPanel + { + Margin = new Thickness(16), + Spacing = 12, + Children = + { + new TextBlock { Text = title }, + textBox, + new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Right, + Children = { cancelButton, okButton }, + }, + }, + }, + }; + + okButton.Click += (_, _) => { result = textBox.Text; dialog.Close(); }; + cancelButton.Click += (_, _) => { result = null; dialog.Close(); }; + dialog.Opened += (_, _) => { textBox.SelectAll(); textBox.Focus(); }; + + await dialog.ShowDialog(owner); + return result; + } + + private async Task CopyToClipboardAsync(string text) + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard is not null) + await clipboard.SetTextAsync(text); + } + + /// Read-only modal listing a remote entry's metadata. + private async Task ShowFilePropertiesAsync(RemoteFileInfoViewModel file) + { + if (TopLevel.GetTopLevel(this) is not Window owner) + return; + + var rows = new StackPanel { Spacing = 6 }; + void AddRow(string label, string value) => + rows.Children.Add(new TextBlock { Text = $"{label}:{value}" }); + + AddRow("名称", file.Name); + AddRow("路径", file.FullPath); + AddRow("类型", file.IsDirectory ? "文件夹" : "文件"); + AddRow("大小", file.FormattedSize); + AddRow("权限", file.Permissions); + AddRow("修改时间", file.FormattedModifiedTime); + + var okButton = new Button + { + Content = "确定", + IsDefault = true, + IsCancel = true, + MinWidth = 72, + HorizontalAlignment = HorizontalAlignment.Right, + }; + + var dialog = new Window + { + Title = "属性", + CanResize = false, + ShowInTaskbar = false, + SizeToContent = SizeToContent.WidthAndHeight, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new StackPanel + { + Margin = new Thickness(16), + Spacing = 12, + Children = { rows, okButton }, + }, + }; + + okButton.Click += (_, _) => dialog.Close(); + await dialog.ShowDialog(owner); + } } \ No newline at end of file diff --git a/src/PulseTerm.App/Views/MainWindow.axaml b/src/PulseTerm.App/Views/MainWindow.axaml index 32a5460a..6497572c 100644 --- a/src/PulseTerm.App/Views/MainWindow.axaml +++ b/src/PulseTerm.App/Views/MainWindow.axaml @@ -26,6 +26,7 @@ + diff --git a/src/PulseTerm.App/Views/MenuBarView.axaml b/src/PulseTerm.App/Views/MenuBarView.axaml index 298f7887..65916880 100644 --- a/src/PulseTerm.App/Views/MenuBarView.axaml +++ b/src/PulseTerm.App/Views/MenuBarView.axaml @@ -277,7 +277,7 @@ -