Skip to content

Commit ef368a1

Browse files
Tomer Vakninclaude
andcommitted
bump version to v1.0.3
- Add SFTP "Move To" for moving remote files between folders - Add "New Folder" to right-click context menus (local and remote) - Disable mirror navigation by default per session Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 569e0dd commit ef368a1

8 files changed

Lines changed: 294 additions & 6 deletions

src/SshManager.App/SshManager.App.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<ImplicitUsings>enable</ImplicitUsings>
88
<UseWPF>true</UseWPF>
99
<ApplicationIcon>Resources\app-icon.ico</ApplicationIcon>
10-
<Version>1.0.2</Version>
10+
<Version>1.0.3</Version>
1111
<!-- Inherited from SshManager.Terminal: SSH.NET vs SshNet.Agent version conflict -->
1212
<NoWarn>$(NoWarn);NU1608</NoWarn>
1313
</PropertyGroup>

src/SshManager.App/ViewModels/SftpBrowserViewModel.cs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,34 @@ public bool IsDeleteDialogVisible
234234

235235
public bool IsDeleteDirectory => DialogState.IsDeleteDirectory;
236236

237+
public bool IsMoveDialogVisible
238+
{
239+
get => DialogState.IsMoveDialogVisible;
240+
set
241+
{
242+
if (DialogState.IsMoveDialogVisible != value)
243+
{
244+
DialogState.IsMoveDialogVisible = value;
245+
OnPropertyChanged();
246+
}
247+
}
248+
}
249+
250+
public string MoveDestinationPath
251+
{
252+
get => DialogState.MoveDestinationPath;
253+
set
254+
{
255+
if (DialogState.MoveDestinationPath != value)
256+
{
257+
DialogState.MoveDestinationPath = value;
258+
OnPropertyChanged();
259+
}
260+
}
261+
}
262+
263+
public string MoveItemName => DialogState.MoveItemName;
264+
237265
// Facade properties for transfers (for XAML binding compatibility)
238266
public ObservableCollection<TransferItemViewModel> Transfers => TransferManager.Transfers;
239267

@@ -418,8 +446,8 @@ public void SetSettingsCallbacks(
418446
_getMirrorNavigationCallback = getMirrorNavigation;
419447
_saveMirrorNavigationCallback = saveMirrorNavigation;
420448

421-
// Load initial value
422-
IsMirrorNavigationEnabled = getMirrorNavigation();
449+
// Mirror navigation always starts disabled — user must opt-in per session
450+
IsMirrorNavigationEnabled = false;
423451

424452
// Set up favorites support for remote browser
425453
RemoteBrowser.SetFavoritesSupport(Hostname, getFavorites, saveFavorites);
@@ -483,6 +511,24 @@ private bool CanShowPermissionsDialog()
483511
[RelayCommand]
484512
private void CancelDelete() => DialogState.CancelDelete();
485513

514+
[RelayCommand]
515+
private async Task ExecuteMoveAsync() => await DialogState.ExecuteMoveAsync();
516+
517+
[RelayCommand]
518+
private void CancelMove() => DialogState.CancelMove();
519+
520+
/// <summary>
521+
/// Shows the move dialog for a remote item.
522+
/// </summary>
523+
public void ShowMoveDialog(FileItemViewModel item)
524+
{
525+
DialogState.ShowMoveDialog(
526+
item.Name,
527+
item.FullPath,
528+
RemoteBrowser.CurrentPath,
529+
item.IsDirectory);
530+
}
531+
486532
// Facade commands for file operations
487533
[RelayCommand]
488534
private async Task DeleteLocalAsync() => await FileOperations.DeleteLocalAsync();
@@ -595,9 +641,10 @@ await TransferManager.DownloadFilesAsync(
595641
/// </summary>
596642
private void CompleteOverwriteDialog(ConflictResolution? resolution)
597643
{
644+
var applyToAll = OverwriteApplyToAll;
598645
DialogState.HideOverwriteDialog();
599646

600-
if (OverwriteApplyToAll && resolution.HasValue)
647+
if (applyToAll && resolution.HasValue)
601648
{
602649
TransferManager.SetApplyToAllResolution(resolution.Value);
603650
}
@@ -902,6 +949,15 @@ private void OnDialogStatePropertyChanged(object? sender, PropertyChangedEventAr
902949
case nameof(DialogState.IsDeleteDirectory):
903950
OnPropertyChanged(nameof(IsDeleteDirectory));
904951
break;
952+
case nameof(DialogState.IsMoveDialogVisible):
953+
OnPropertyChanged(nameof(IsMoveDialogVisible));
954+
break;
955+
case nameof(DialogState.MoveDestinationPath):
956+
OnPropertyChanged(nameof(MoveDestinationPath));
957+
break;
958+
case nameof(DialogState.MoveItemName):
959+
OnPropertyChanged(nameof(MoveItemName));
960+
break;
905961
}
906962
}
907963

src/SshManager.App/ViewModels/SftpDialogStateViewModel.cs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,34 @@ public partial class SftpDialogStateViewModel : ObservableObject
144144
/// </summary>
145145
private Func<Task>? _pendingDeleteAction;
146146

147+
/// <summary>
148+
/// Whether the move dialog is visible.
149+
/// </summary>
150+
[ObservableProperty]
151+
private bool _isMoveDialogVisible;
152+
153+
/// <summary>
154+
/// The destination path for the move operation.
155+
/// </summary>
156+
[ObservableProperty]
157+
private string _moveDestinationPath = "";
158+
159+
/// <summary>
160+
/// The name of the item being moved (display only).
161+
/// </summary>
162+
[ObservableProperty]
163+
private string _moveItemName = "";
164+
165+
/// <summary>
166+
/// The full source path of the item being moved.
167+
/// </summary>
168+
private string _moveSourcePath = "";
169+
170+
/// <summary>
171+
/// Whether the item being moved is a directory.
172+
/// </summary>
173+
private bool _moveIsDirectory;
174+
147175
public string OverwriteSizeDisplay => OverwriteTotalSize > 0
148176
? $"Existing: {FormatFileSize(OverwriteExistingSize)} of {FormatFileSize(OverwriteTotalSize)}"
149177
: $"Existing: {FormatFileSize(OverwriteExistingSize)}";
@@ -461,6 +489,92 @@ public void CancelDelete()
461489
_pendingDeleteAction = null;
462490
}
463491

492+
/// <summary>
493+
/// Shows the move dialog for a remote item.
494+
/// </summary>
495+
/// <param name="itemName">Display name of the item.</param>
496+
/// <param name="sourcePath">Full remote path of the item.</param>
497+
/// <param name="currentDirectory">Current remote directory (used as default destination).</param>
498+
/// <param name="isDirectory">Whether the item is a directory.</param>
499+
public void ShowMoveDialog(string itemName, string sourcePath, string currentDirectory, bool isDirectory)
500+
{
501+
MoveItemName = itemName;
502+
_moveSourcePath = sourcePath;
503+
_moveIsDirectory = isDirectory;
504+
MoveDestinationPath = currentDirectory;
505+
IsMoveDialogVisible = true;
506+
}
507+
508+
/// <summary>
509+
/// Executes the move operation.
510+
/// </summary>
511+
[RelayCommand]
512+
public async Task ExecuteMoveAsync()
513+
{
514+
if (string.IsNullOrWhiteSpace(MoveDestinationPath))
515+
{
516+
SetErrorMessageAction?.Invoke("Please enter a destination path.");
517+
return;
518+
}
519+
520+
var destinationDir = MoveDestinationPath.Trim();
521+
522+
// Validate: reject null bytes and ".." traversal
523+
if (destinationDir.Contains('\0') || destinationDir.Contains(".."))
524+
{
525+
SetErrorMessageAction?.Invoke("Destination path contains invalid characters.");
526+
return;
527+
}
528+
529+
// Build the full destination path: destination directory + item name
530+
var itemName = _moveSourcePath.Contains('/')
531+
? _moveSourcePath[(_moveSourcePath.LastIndexOf('/') + 1)..]
532+
: _moveSourcePath;
533+
534+
var destPath = destinationDir.EndsWith('/')
535+
? destinationDir + itemName
536+
: destinationDir + "/" + itemName;
537+
538+
// Don't move to the same location
539+
if (string.Equals(destPath, _moveSourcePath, StringComparison.Ordinal))
540+
{
541+
IsMoveDialogVisible = false;
542+
MoveDestinationPath = "";
543+
return;
544+
}
545+
546+
SetErrorMessageAction?.Invoke(null);
547+
548+
try
549+
{
550+
await _session.RenameAsync(_moveSourcePath, destPath);
551+
_logger.LogInformation("Moved remote item from {OldPath} to {NewPath}", _moveSourcePath, destPath);
552+
553+
IsMoveDialogVisible = false;
554+
MoveDestinationPath = "";
555+
556+
if (RefreshRemoteBrowserCallback != null)
557+
{
558+
await RefreshRemoteBrowserCallback();
559+
}
560+
}
561+
catch (Exception ex)
562+
{
563+
_logger.LogError(ex, "Failed to move {Path} to {Destination}", _moveSourcePath, destPath);
564+
SetErrorMessageAction?.Invoke($"Failed to move: {ex.Message}");
565+
}
566+
}
567+
568+
/// <summary>
569+
/// Cancels the move operation.
570+
/// </summary>
571+
[RelayCommand]
572+
public void CancelMove()
573+
{
574+
IsMoveDialogVisible = false;
575+
MoveDestinationPath = "";
576+
}
577+
464578
private static bool TryParsePermissions(string? input, out int permissions)
465579
{
466580
permissions = 0;

src/SshManager.App/Views/Controls/FileBrowserControlBase.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ private struct POINT
7575
/// </summary>
7676
public event EventHandler<FilesTransferRequestedEventArgs>? DownloadRequested;
7777

78+
/// <summary>
79+
/// Event raised when move is requested for the selected item.
80+
/// </summary>
81+
public event EventHandler<FileItemViewModel>? MoveRequested;
82+
83+
/// <summary>
84+
/// Event raised when new folder creation is requested from context menu.
85+
/// </summary>
86+
public event EventHandler? NewFolderRequested;
87+
7888
/// <summary>
7989
/// Gets the data key used for drag operations (e.g., "LocalFilePaths" or "RemoteFilePaths").
8090
/// </summary>
@@ -482,6 +492,25 @@ protected void ContextMenu_Delete_Click(object sender, RoutedEventArgs e)
482492
DeleteRequested?.Invoke(this, EventArgs.Empty);
483493
}
484494

495+
/// <summary>
496+
/// Requests move for the selected item.
497+
/// </summary>
498+
protected void ContextMenu_MoveTo_Click(object sender, RoutedEventArgs e)
499+
{
500+
if (FileListView.SelectedItem is FileItemViewModel item && !item.IsParentDirectory)
501+
{
502+
MoveRequested?.Invoke(this, item);
503+
}
504+
}
505+
506+
/// <summary>
507+
/// Requests new folder creation.
508+
/// </summary>
509+
protected void ContextMenu_NewFolder_Click(object sender, RoutedEventArgs e)
510+
{
511+
NewFolderRequested?.Invoke(this, EventArgs.Empty);
512+
}
513+
485514
/// <summary>
486515
/// Requests upload for the selected files.
487516
/// </summary>

src/SshManager.App/Views/Controls/LocalFileBrowserControl.xaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,9 @@
362362
Icon="{ui:SymbolIcon FolderOpen24}"
363363
Click="ContextMenu_Open_Click"
364364
InputGestureText="Enter" />
365+
<ui:MenuItem Header="New Folder"
366+
Icon="{ui:SymbolIcon FolderAdd24}"
367+
Click="ContextMenu_NewFolder_Click" />
365368
<ui:MenuItem Header="Edit"
366369
Icon="{ui:SymbolIcon Edit24}"
367370
Click="ContextMenu_Edit_Click"

src/SshManager.App/Views/Controls/RemoteFileBrowserControl.xaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,9 @@
467467
Icon="{ui:SymbolIcon FolderOpen24}"
468468
Click="ContextMenu_Open_Click"
469469
InputGestureText="Enter" />
470+
<ui:MenuItem Header="New Folder"
471+
Icon="{ui:SymbolIcon FolderAdd24}"
472+
Click="ContextMenu_NewFolder_Click" />
470473
<ui:MenuItem Header="Edit"
471474
Icon="{ui:SymbolIcon Edit24}"
472475
Click="ContextMenu_Edit_Click"
@@ -486,6 +489,9 @@
486489
Icon="{ui:SymbolIcon Rename24}"
487490
Click="ContextMenu_Rename_Click"
488491
InputGestureText="F2" />
492+
<ui:MenuItem Header="Move To..."
493+
Icon="{ui:SymbolIcon ArrowForward24}"
494+
Click="ContextMenu_MoveTo_Click" />
489495
<ui:MenuItem Header="Delete"
490496
Icon="{ui:SymbolIcon Delete24}"
491497
Click="ContextMenu_Delete_Click"

src/SshManager.App/Views/Controls/SftpBrowserControl.xaml

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,8 @@
455455
FilesDroppedFromRemote="LocalBrowser_FilesDroppedFromRemote"
456456
DeleteRequested="LocalBrowser_DeleteRequested"
457457
EditRequested="LocalBrowser_EditRequested"
458-
UploadRequested="LocalBrowser_UploadRequested" />
458+
UploadRequested="LocalBrowser_UploadRequested"
459+
NewFolderRequested="LocalBrowser_NewFolderRequested" />
459460

460461
<!-- Collapse Local Panel Button -->
461462
<ui:Button Grid.Column="1"
@@ -552,7 +553,9 @@
552553
FilesDroppedFromLocal="RemoteBrowser_FilesDroppedFromLocal"
553554
DeleteRequested="RemoteBrowser_DeleteRequested"
554555
EditRequested="RemoteBrowser_EditRequested"
555-
DownloadRequested="RemoteBrowser_DownloadRequested" />
556+
DownloadRequested="RemoteBrowser_DownloadRequested"
557+
MoveRequested="RemoteBrowser_MoveRequested"
558+
NewFolderRequested="RemoteBrowser_NewFolderRequested" />
556559
</Grid>
557560

558561
<!-- Transfer Progress Panel -->
@@ -915,6 +918,59 @@
915918
</Border>
916919
</Border>
917920

921+
<!-- Move Dialog Overlay -->
922+
<Border Grid.RowSpan="3"
923+
Background="#80000000"
924+
Visibility="{Binding IsMoveDialogVisible, Converter={StaticResource BoolToVisibilityConverter}}">
925+
<Border Background="{ui:ThemeResource ApplicationBackgroundBrush}"
926+
CornerRadius="8"
927+
Padding="24"
928+
HorizontalAlignment="Center"
929+
VerticalAlignment="Center"
930+
MinWidth="400">
931+
<StackPanel>
932+
<TextBlock Text="Move Item"
933+
FontSize="16"
934+
FontWeight="SemiBold"
935+
Margin="0,0,0,12" />
936+
937+
<!-- Item being moved -->
938+
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
939+
<ui:SymbolIcon Symbol="ArrowForward24"
940+
FontSize="14"
941+
Foreground="{ui:ThemeResource AccentTextFillColorPrimaryBrush}"
942+
Margin="0,0,8,0" />
943+
<TextBlock Text="{Binding MoveItemName}"
944+
FontWeight="SemiBold"
945+
TextTrimming="CharacterEllipsis" />
946+
</StackPanel>
947+
948+
<TextBlock Text="Destination Folder"
949+
FontSize="12"
950+
Opacity="0.7"
951+
Margin="0,0,0,4" />
952+
953+
<ui:TextBox x:Name="MoveDestinationTextBox"
954+
PlaceholderText="Enter destination path (e.g. /home/user/folder)"
955+
Text="{Binding MoveDestinationPath, UpdateSourceTrigger=PropertyChanged}"
956+
Margin="0,0,0,16" />
957+
958+
<StackPanel Orientation="Horizontal"
959+
HorizontalAlignment="Right">
960+
<ui:Button Content="Cancel"
961+
Command="{Binding CancelMoveCommand}"
962+
Padding="16,8"
963+
Margin="0,0,8,0" />
964+
<ui:Button Content="Move"
965+
Appearance="Primary"
966+
Icon="{ui:SymbolIcon ArrowForward24}"
967+
Command="{Binding ExecuteMoveCommand}"
968+
Padding="16,8" />
969+
</StackPanel>
970+
</StackPanel>
971+
</Border>
972+
</Border>
973+
918974
<!-- Error Message Banner -->
919975
<Border Grid.Row="0"
920976
Background="#E74C3C"

0 commit comments

Comments
 (0)