Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,5 @@ FodyWeavers.xsd
.idea/

/obdata
.omc
.sisyphus
276 changes: 245 additions & 31 deletions src/PulseTerm.App/ViewModels/FileBrowserViewModel.cs

Large diffs are not rendered by default.

65 changes: 64 additions & 1 deletion src/PulseTerm.App/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,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));
Expand All @@ -160,11 +163,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(_ => { }, _ => { });
Comment on lines +174 to +175
}

/// <summary>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.</summary>
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(_ => { }, _ => { });
Comment on lines +188 to +189
}

/// <summary>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.</summary>
private void ShowFileBrowserForActiveSession()
{
RebindFileBrowser();

if (_sftpService is null || FileBrowser.SessionId == Guid.Empty)
return;

FileBrowser.IsVisible = true;
FileBrowser.LoadInitialCommand.Execute().Subscribe(_ => { }, _ => { });
}
/// <summary>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).</summary>
Expand Down Expand Up @@ -315,6 +351,12 @@ public async Task<TerminalTabViewModel> 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();

if (_metricsService is not null)
terminalTab.ResourceMonitor = new ResourceMonitorViewModel(_metricsService, session.SessionId, terminalTab.Title);
}
Expand Down Expand Up @@ -380,6 +422,9 @@ public async Task ReconnectTabAsync(TerminalTabViewModel tab, CancellationToken
if (_metricsService is not null)
tab.ResourceMonitor = new ResourceMonitorViewModel(_metricsService, session.SessionId, tab.Title);

// Reconnect mints a fresh session id; rebind the SFTP browser to it and reload (#22).
ShowFileBrowserForActiveSession();

StatusBar.ResetUptime();
UpdateStatusBarForActiveTab();
LastConnectionError = null;
Expand Down Expand Up @@ -563,9 +608,27 @@ private void OnDocumentClosed(TerminalDocument document)
{
TabBar.CloseTabCommand.Execute(tab).Subscribe();
}
CloseSftpForTab(tab);
tab.Dispose();
}

/// <summary>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).</summary>
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;
Expand Down
174 changes: 116 additions & 58 deletions src/PulseTerm.App/Views/FileBrowserView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,27 @@
<Style Selector="Button.toolbar-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PulseBgHover}" />
</Style>
<Style Selector="Button.col-header">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.col-header:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PulseBgHover}" />
</Style>
</UserControl.Styles>

<Border BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource PulseBorderPrimary}"
Background="{DynamicResource PulseBgPage}"
IsVisible="{Binding IsVisible}"
MinHeight="120"
Height="220">
VerticalAlignment="Stretch"
MinHeight="120">

<Panel>
<DockPanel>
Expand Down Expand Up @@ -86,30 +99,6 @@
Data="{StaticResource Icon.corner-left-up}"
Foreground="{DynamicResource PulseTextTertiary}" />
</Button>
<Button Classes="toolbar-btn" Width="24" Height="24" Padding="0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Command="{Binding CreateFolderCommand}"
ToolTip.Tip="新建文件夹">
<pc:LucideIcon Width="13" Height="13"
Data="{StaticResource Icon.folder}"
Foreground="{DynamicResource PulseTextTertiary}" />
</Button>
<Button Classes="toolbar-btn" Width="24" Height="24" Padding="0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Command="{Binding DownloadCommand}"
ToolTip.Tip="{x:Static res:Strings.Download}">
<pc:LucideIcon Width="13" Height="13"
Data="{StaticResource Icon.arrow-up-down}"
Foreground="{DynamicResource PulseTextTertiary}" />
</Button>
<Button Classes="toolbar-btn" Width="24" Height="24" Padding="0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Command="{Binding DeleteCommand}"
ToolTip.Tip="{x:Static res:Strings.Delete}">
<pc:LucideIcon Width="13" Height="13"
Data="{StaticResource Icon.x}"
Foreground="{DynamicResource PulseTextTertiary}" />
</Button>
</StackPanel>
</Grid>
</Border>
Expand All @@ -131,38 +120,78 @@
BorderThickness="0,0,0,1"
Padding="14,0">
<Grid ColumnDefinitions="*,100,110,140">
<TextBlock Grid.Column="0"
Text="{x:Static res:Strings.FileName}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}"
FontSize="10"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
Text="{x:Static res:Strings.Size}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}"
FontSize="10"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2"
Text="{x:Static res:Strings.Permissions}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}"
FontSize="10"
VerticalAlignment="Center" />
<TextBlock Grid.Column="3"
Text="{x:Static res:Strings.Modified}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}"
FontSize="10"
VerticalAlignment="Center" />
<Button Grid.Column="0" Classes="col-header"
Command="{Binding SortCommand}" CommandParameter="name">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{x:Static res:Strings.FileName}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
<TextBlock Text="{Binding NameSortGlyph}"
Foreground="{DynamicResource PulseAccent}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="col-header"
Command="{Binding SortCommand}" CommandParameter="size">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{x:Static res:Strings.Size}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
<TextBlock Text="{Binding SizeSortGlyph}"
Foreground="{DynamicResource PulseAccent}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
</StackPanel>
</Button>
<Button Grid.Column="2" Classes="col-header"
Command="{Binding SortCommand}" CommandParameter="permissions">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{x:Static res:Strings.Permissions}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
<TextBlock Text="{Binding PermissionsSortGlyph}"
Foreground="{DynamicResource PulseAccent}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
</StackPanel>
</Button>
<Button Grid.Column="3" Classes="col-header"
Command="{Binding SortCommand}" CommandParameter="modified">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{x:Static res:Strings.Modified}"
Foreground="{DynamicResource PulseTextMuted}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
<TextBlock Text="{Binding ModifiedSortGlyph}"
Foreground="{DynamicResource PulseAccent}"
FontFamily="{DynamicResource PulseTerminalFont}" FontSize="10" />
</StackPanel>
</Button>
</Grid>
</Border>

<ScrollViewer Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<ListBox Grid.Row="1"
x:Name="FileList"
ItemsSource="{Binding Files}"
SelectedItems="{Binding SelectedFiles}"
SelectionMode="Multiple"
DoubleTapped="OnFileDoubleTapped"
Background="Transparent"
Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
</ListBox.Styles>
<ListBox.ContextMenu>
<ContextMenu x:DataType="vm:FileBrowserViewModel">
<MenuItem Header="上传到当前文件夹" Command="{Binding UploadCommand}" />
<MenuItem Header="新建文件夹" Command="{Binding NewFolderCommand}" />
<MenuItem Header="新建文件" Command="{Binding NewFileCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteFileInfoViewModel">
<Border Background="Transparent"
BorderBrush="{DynamicResource PulseBorderSecondary}"
Expand All @@ -174,6 +203,36 @@
<Setter Property="Background" Value="{DynamicResource PulseBgHover}" />
</Style>
</Border.Styles>
<Border.ContextMenu>
<ContextMenu x:DataType="vm:RemoteFileInfoViewModel">
<MenuItem Header="下载"
IsVisible="{Binding !IsDirectory}"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).DownloadItemCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="重命名"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).RenameCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="移动"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).MoveCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="复制文件路径"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).CopyPathCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="复制文件名称"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).CopyNameCommand}"
CommandParameter="{Binding}" />
<MenuItem Header="属性"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).PropertiesCommand}"
CommandParameter="{Binding}" />
<Separator />
<MenuItem Header="上传到当前文件夹"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).UploadCommand}" />
<MenuItem Header="新建文件夹"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).NewFolderCommand}" />
<MenuItem Header="新建文件"
Command="{Binding $parent[ListBox].((vm:FileBrowserViewModel)DataContext).NewFileCommand}" />
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="*,100,110,140">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Expand Down Expand Up @@ -218,9 +277,8 @@
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</ListBox.ItemTemplate>
</ListBox>

</Grid>

Expand Down
Loading