init FileBrowser

This commit is contained in:
2025-08-07 22:17:36 +02:00
commit d90dfab37a
13 changed files with 599 additions and 0 deletions

88
.gitignore vendored Normal file
View File

@@ -0,0 +1,88 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Build results
[Bb]in/
[Oo]bj/
[Ll]og/
*.user
*.suo
*.userosscache
*.sln.docstates
# .NET Core build results
project.lock.json
project.fragment.lock.json
artifacts/
# Windows image file caches
Thumbs.db
ehthumbs.db
# Visual Studio for Mac
.vscode/
.idea/
*.userprefs
# Rider
*.sln.iml
# Web/ASP.NET (not needed for WPF, but often included)
*.publish.xml
*.azurePubxml
*.azurePubxml.user
PublishProfiles/
# NuGet
*.nupkg
*.snupkg
.nuget/
packages/
*.nuspec
project.assets.json
PackageRestoreEnabled.txt
# MSTest test results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# Visual Studio Code settings
.vscode/
# Backup & temp files
*.bak
*.tmp
*.temp
*.log
# Resharper
_ReSharper*/
*.DotSettings.user
# Rider
.idea/
# WPF specific (if generated)
Generated_Code/
# Local config files
appsettings.Development.json
app.config
web.config
# Crash reports
*.dmp
# Others
*.dbmdl
*.jfm
*.sdf
*.cache
*.pdb
*.mdb
# Ignore git itself
.git/
.vs/

25
FileBrowser.sln Normal file
View File

@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35208.52
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileBrowser", "FileBrowser\FileBrowser.csproj", "{13CB098D-6CE7-4FC9-8E05-B5D9D3977B88}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{13CB098D-6CE7-4FC9-8E05-B5D9D3977B88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{13CB098D-6CE7-4FC9-8E05-B5D9D3977B88}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13CB098D-6CE7-4FC9-8E05-B5D9D3977B88}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13CB098D-6CE7-4FC9-8E05-B5D9D3977B88}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {567C0FE1-762F-4A8A-8F95-CEE68A3BDCF5}
EndGlobalSection
EndGlobal

9
FileBrowser/App.xaml Normal file
View File

@@ -0,0 +1,9 @@
<Application x:Class="FileBrowser.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:FileBrowser"
Startup="OnStartup">
<Application.Resources>
</Application.Resources>
</Application>

30
FileBrowser/App.xaml.cs Normal file
View File

@@ -0,0 +1,30 @@
using FileBrowser.Services;
using FileBrowser.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using System.Windows;
namespace FileBrowser;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
private IServiceProvider _provider;
private void OnStartup(object sender, StartupEventArgs e)
{
var services = new ServiceCollection();
services.AddSingleton<IFileService, FileService>();
services.AddSingleton<MainViewModel>();
services.AddTransient<MainWindow>(); // window created with DI
_provider = services.BuildServiceProvider();
var wnd = _provider.GetRequiredService<MainWindow>();
// Set DataContext with DI-provided VM (no code-behind in view)
wnd.DataContext = _provider.GetRequiredService<MainViewModel>();
wnd.Show();
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using System.Windows.Input;
namespace FileBrowser.Helpers;
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -0,0 +1,73 @@
<Window x:Class="FileBrowser.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:FileBrowser"
mc:Ignorable="d"
Title="Keyboard File Browser" Height="600" Width="1000">
<Window.InputBindings>
<!-- Tab: Enter / Open -->
<KeyBinding Key="Tab" Command="{Binding EnterCommand}" />
<!-- Shift+Tab: Up one folder -->
<KeyBinding Key="Tab" Modifiers="Shift" Command="{Binding UpCommand}" />
<!-- F5 refresh -->
<KeyBinding Key="F5" Command="{Binding RefreshCommand}" />
</Window.InputBindings>
<DockPanel LastChildFill="True" Margin="6">
<!-- Top: Path -->
<TextBlock DockPanel.Dock="Top" Text="{Binding CurrentPath}" d:Text="c:/Test" FontWeight="Bold"
Padding="4" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="4*" />
<ColumnDefinition Width="3*" />
</Grid.ColumnDefinitions>
<!-- Left: Parent folder contents -->
<Border Grid.Column="0" BorderBrush="Gray" BorderThickness="1" Margin="4"
Padding="4">
<StackPanel>
<TextBlock Text="Parent / Siblings" FontWeight="SemiBold" />
<ListBox ItemsSource="{Binding ParentItems}"
SelectedItem="{Binding SelectedParentItem}"
DisplayMemberPath="Name"
KeyboardNavigation.TabNavigation="Local"
Focusable="False" />
</StackPanel>
</Border>
<!-- Middle: Current folder contents and filter input (keyboard-only) -->
<Border Grid.Column="1" BorderBrush="Gray" BorderThickness="1" Margin="4"
Padding="4">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Current Folder (type to filter)" />
<!-- Hidden TextBox takes all typing for filter -->
<TextBox x:Name="FilterBox"
Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged}"
Height="0.0" Opacity="0" Focusable="True"/>
<ListBox ItemsSource="{Binding CurrentView}"
SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
DisplayMemberPath="Name"
IsSynchronizedWithCurrentItem="True"
KeyboardNavigation.TabNavigation="Local" />
</DockPanel>
</Border>
<!-- Right: Preview -->
<Border Grid.Column="2" BorderBrush="Gray" BorderThickness="1" Margin="4"
Padding="4">
<StackPanel>
<TextBlock Text="Preview" FontWeight="SemiBold" />
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<!-- Einfacher Text-Preview -->
<TextBlock Text="{Binding PreviewText}" TextWrapping="Wrap" />
</ScrollViewer>
</StackPanel>
</Border>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,14 @@
using System.Windows;
namespace FileBrowser;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,10 @@
using System.IO;
namespace FileBrowser.Models;
public class FileItem
{
public string FullPath { get; set; }
public string Name => Path.GetFileName(FullPath) ?? FullPath;
public bool IsDirectory => Directory.Exists(FullPath);
}

View File

@@ -0,0 +1,51 @@
using FileBrowser.Models;
using System.IO;
namespace FileBrowser.Services;
public class FileService : IFileService
{
public string GetParentDirectory(string path)
{
try
{
var di = Directory.GetParent(path);
return di?.FullName ?? path;
}
catch { return path; }
}
public IEnumerable<FileItem> GetDirectoryItems(string path)
{
try
{
var dirs = Directory.EnumerateDirectories(path)
.Select(d => new FileItem { FullPath = d });
var files = Directory.EnumerateFiles(path)
.Select(f => new FileItem { FullPath = f });
return dirs.Concat(files).OrderBy(i => i.IsDirectory ? 0 : 1).ThenBy(i => i.Name);
}
catch { return Array.Empty<FileItem>(); }
}
public string? ReadTextPreview(string path, int maxChars = 20000)
{
try
{
var ext = Path.GetExtension(path)?.ToLowerInvariant();
var textExts = new[] { ".txt", ".cs", ".config", ".json", ".xml", ".log", ".md", ".csv" };
if (!textExts.Contains(ext)) return null;
using var sr = new StreamReader(path);
var s = sr.ReadToEnd();
if (s.Length > maxChars) s = s.Substring(0, maxChars) + "\n... (truncated)";
return s;
}
catch { return null; }
}
public bool IsImage(string path)
{
var ext = Path.GetExtension(path)?.ToLowerInvariant();
return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || ext == ".gif";
}
}

View File

@@ -0,0 +1,11 @@
using FileBrowser.Models;
namespace FileBrowser.Services;
public interface IFileService
{
string GetParentDirectory(string path);
IEnumerable<FileItem> GetDirectoryItems(string path);
string? ReadTextPreview(string path, int maxChars = 20000);
bool IsImage(string path);
}

View File

@@ -0,0 +1,242 @@
using FileBrowser.Helpers;
using FileBrowser.Models;
using FileBrowser.Services;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows.Data;
using System.Windows.Input;
namespace FileBrowser.ViewModels;
public class MainViewModel : INotifyPropertyChanged
{
private readonly IFileService _fileService;
public MainViewModel(IFileService fileService)
{
_fileService = fileService ?? throw new ArgumentNullException(nameof(fileService));
ParentItems = new ObservableCollection<FileItem>();
CurrentItems = new ObservableCollection<FileItem>();
// Commands zuerst initialisieren
EnterCommand = new RelayCommand(_ => EnterOrOpen(), _ => SelectedItem != null);
UpCommand = new RelayCommand(_ => Up(), _ => CanGoUp());
RefreshCommand = new RelayCommand(_ => Refresh());
// CollectionView danach erzeugen
_currentView = CollectionViewSource.GetDefaultView(CurrentItems);
if (_currentView != null)
_currentView.Filter = FilterPredicate;
// Defaults
CurrentPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? @"C:\";
FilterText = string.Empty;
// Jetzt sicher Refresh aufrufen (SelectedItem kann gesetzt werden ohne Nullref)
try { Refresh(); }
catch (Exception ex) { Debug.WriteLine("Initial Refresh failed: " + ex); }
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Raise(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _currentPath = "";
public string CurrentPath
{
get => _currentPath;
set
{
if (_currentPath == value) return;
_currentPath = value;
Raise(nameof(CurrentPath));
Refresh();
}
}
public ObservableCollection<FileItem> ParentItems { get; } = new ObservableCollection<FileItem>();
public ObservableCollection<FileItem> CurrentItems { get; } = new ObservableCollection<FileItem>();
private ICollectionView _currentView;
public ICollectionView CurrentView => _currentView;
private FileItem? _selectedParentItem;
public FileItem? SelectedParentItem
{
get => _selectedParentItem;
set
{
_selectedParentItem = value;
if (value != null)
{
// selecting a sibling sets CurrentPath to its parent (no auto-enter)
}
Raise(nameof(SelectedParentItem));
}
}
private FileItem? _selectedItem;
public FileItem? SelectedItem
{
get => _selectedItem;
set
{
_selectedItem = value;
UpdatePreview();
Raise(nameof(SelectedItem));
// Sicherstellen, dass EnterCommand existiert und vom erwarteten Typ ist,
// bevor RaiseCanExecuteChanged aufgerufen wird.
if (EnterCommand is RelayCommand rc)
{
rc.RaiseCanExecuteChanged();
}
}
}
private string? _previewText;
public string? PreviewText
{
get => _previewText;
set { _previewText = value; Raise(nameof(PreviewText)); }
}
private string _filterText = "";
public string FilterText
{
get => _filterText;
set
{
_filterText = value;
_currentView.Refresh();
Raise(nameof(FilterText));
}
}
private bool FilterPredicate(object obj)
{
if (obj is FileItem fi)
{
if (string.IsNullOrEmpty(FilterText)) return true;
return fi.Name.IndexOf(FilterText, StringComparison.OrdinalIgnoreCase) >= 0;
}
return true;
}
private void UpdatePreview()
{
PreviewText = null;
if (SelectedItem == null) return;
// Schütze _fileService gegen Null
if (_fileService == null) return;
if (SelectedItem.IsDirectory) return;
var txt = _fileService.ReadTextPreview(SelectedItem.FullPath);
if (txt != null)
{
PreviewText = txt;
return;
}
if (_fileService.IsImage(SelectedItem.FullPath))
{
PreviewText = SelectedItem.FullPath;
}
}
public ICommand EnterCommand { get; }
public ICommand UpCommand { get; }
public ICommand RefreshCommand { get; }
private void Refresh()
{
// Defensive programming to avoid NullReferenceExceptions
if (ParentItems == null || CurrentItems == null)
throw new InvalidOperationException("Collections were not initialized.");
// clear existing
ParentItems.Clear();
CurrentItems.Clear();
// normalize CurrentPath
var path = string.IsNullOrWhiteSpace(CurrentPath) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : CurrentPath;
// Determine parent folder for sibling listing; if none, use the same path
string parentForSiblings;
try
{
var parentDirInfo = Directory.GetParent(path);
parentForSiblings = parentDirInfo?.FullName ?? path;
}
catch (Exception ex)
{
Debug.WriteLine("Directory.GetParent failed: " + ex);
parentForSiblings = path;
}
// Get items safely from service (service should itself be robust)
IEnumerable<FileItem> parentItems;
try { parentItems = _fileService.GetDirectoryItems(parentForSiblings) ?? Array.Empty<FileItem>(); }
catch (Exception ex) { Debug.WriteLine("GetDirectoryItems(parent) failed: " + ex); parentItems = Array.Empty<FileItem>(); }
foreach (var item in parentItems) ParentItems.Add(item);
IEnumerable<FileItem> currentItems;
try { currentItems = _fileService.GetDirectoryItems(path) ?? Array.Empty<FileItem>(); }
catch (Exception ex) { Debug.WriteLine("GetDirectoryItems(current) failed: " + ex); currentItems = Array.Empty<FileItem>(); }
foreach (var item in currentItems) CurrentItems.Add(item);
// Refresh view safely
try { _currentView?.Refresh(); }
catch (Exception ex) { Debug.WriteLine("CollectionView.Refresh failed: " + ex); }
// Select first available safely
SelectedItem = CurrentItems.FirstOrDefault();
SelectedParentItem = ParentItems.FirstOrDefault(o => o.FullPath.Contains(CurrentPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)));
// Notify (collections changed notifications come from ObservableCollection,
// but we raise to be safe)
Raise(nameof(ParentItems));
Raise(nameof(CurrentItems));
Raise(nameof(CurrentView));
}
private bool CanGoUp() => Directory.GetParent(CurrentPath) != null;
private void Up()
{
var parent = Directory.GetParent(CurrentPath)?.FullName;
if (parent != null)
{
CurrentPath = parent;
FilterText = "";
}
}
private void EnterOrOpen()
{
if (SelectedItem == null) return;
if (SelectedItem.IsDirectory)
{
CurrentPath = SelectedItem.FullPath;
FilterText = "";
}
else
{
try
{
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = SelectedItem.FullPath,
UseShellExecute = true
};
Process.Start(psi);
}
catch { /* ignore failures */ }
}
}
}