From d90dfab37a01489c367cdd4f815ba5da61d739f4 Mon Sep 17 00:00:00 2001 From: jafreli Date: Thu, 7 Aug 2025 22:17:36 +0200 Subject: [PATCH] init FileBrowser --- .gitignore | 88 +++++++++ FileBrowser.sln | 25 +++ FileBrowser/App.xaml | 9 + FileBrowser/App.xaml.cs | 30 +++ FileBrowser/AssemblyInfo.cs | 10 + FileBrowser/FileBrowser.csproj | 15 ++ FileBrowser/Helpers/RelayCommand.cs | 21 ++ FileBrowser/MainWindow.xaml | 73 +++++++ FileBrowser/MainWindow.xaml.cs | 14 ++ FileBrowser/Models/FileItem.cs | 10 + FileBrowser/Services/FileService.cs | 51 +++++ FileBrowser/Services/IFileService.cs | 11 ++ FileBrowser/ViewModels/MainViewModel.cs | 242 ++++++++++++++++++++++++ 13 files changed, 599 insertions(+) create mode 100644 .gitignore create mode 100644 FileBrowser.sln create mode 100644 FileBrowser/App.xaml create mode 100644 FileBrowser/App.xaml.cs create mode 100644 FileBrowser/AssemblyInfo.cs create mode 100644 FileBrowser/FileBrowser.csproj create mode 100644 FileBrowser/Helpers/RelayCommand.cs create mode 100644 FileBrowser/MainWindow.xaml create mode 100644 FileBrowser/MainWindow.xaml.cs create mode 100644 FileBrowser/Models/FileItem.cs create mode 100644 FileBrowser/Services/FileService.cs create mode 100644 FileBrowser/Services/IFileService.cs create mode 100644 FileBrowser/ViewModels/MainViewModel.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eec0e6 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/FileBrowser.sln b/FileBrowser.sln new file mode 100644 index 0000000..1b54819 --- /dev/null +++ b/FileBrowser.sln @@ -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 diff --git a/FileBrowser/App.xaml b/FileBrowser/App.xaml new file mode 100644 index 0000000..1ba9488 --- /dev/null +++ b/FileBrowser/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/FileBrowser/App.xaml.cs b/FileBrowser/App.xaml.cs new file mode 100644 index 0000000..85d1818 --- /dev/null +++ b/FileBrowser/App.xaml.cs @@ -0,0 +1,30 @@ +using FileBrowser.Services; +using FileBrowser.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using System.Windows; + +namespace FileBrowser; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : Application +{ + private IServiceProvider _provider; + + private void OnStartup(object sender, StartupEventArgs e) + { + var services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); // window created with DI + + _provider = services.BuildServiceProvider(); + + var wnd = _provider.GetRequiredService(); + // Set DataContext with DI-provided VM (no code-behind in view) + wnd.DataContext = _provider.GetRequiredService(); + wnd.Show(); + } +} diff --git a/FileBrowser/AssemblyInfo.cs b/FileBrowser/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/FileBrowser/AssemblyInfo.cs @@ -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) +)] diff --git a/FileBrowser/FileBrowser.csproj b/FileBrowser/FileBrowser.csproj new file mode 100644 index 0000000..d6ea538 --- /dev/null +++ b/FileBrowser/FileBrowser.csproj @@ -0,0 +1,15 @@ + + + + WinExe + net8.0-windows + enable + enable + true + + + + + + + diff --git a/FileBrowser/Helpers/RelayCommand.cs b/FileBrowser/Helpers/RelayCommand.cs new file mode 100644 index 0000000..b90888b --- /dev/null +++ b/FileBrowser/Helpers/RelayCommand.cs @@ -0,0 +1,21 @@ +using System.Windows.Input; + +namespace FileBrowser.Helpers; + +public class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + public RelayCommand(Action execute, Func? 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); +} diff --git a/FileBrowser/MainWindow.xaml b/FileBrowser/MainWindow.xaml new file mode 100644 index 0000000..e3e4320 --- /dev/null +++ b/FileBrowser/MainWindow.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FileBrowser/MainWindow.xaml.cs b/FileBrowser/MainWindow.xaml.cs new file mode 100644 index 0000000..a3bd956 --- /dev/null +++ b/FileBrowser/MainWindow.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows; + +namespace FileBrowser; + +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/FileBrowser/Models/FileItem.cs b/FileBrowser/Models/FileItem.cs new file mode 100644 index 0000000..1626ba8 --- /dev/null +++ b/FileBrowser/Models/FileItem.cs @@ -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); +} diff --git a/FileBrowser/Services/FileService.cs b/FileBrowser/Services/FileService.cs new file mode 100644 index 0000000..741694c --- /dev/null +++ b/FileBrowser/Services/FileService.cs @@ -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 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(); } + } + + 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"; + } +} diff --git a/FileBrowser/Services/IFileService.cs b/FileBrowser/Services/IFileService.cs new file mode 100644 index 0000000..d8a0858 --- /dev/null +++ b/FileBrowser/Services/IFileService.cs @@ -0,0 +1,11 @@ +using FileBrowser.Models; + +namespace FileBrowser.Services; + +public interface IFileService +{ + string GetParentDirectory(string path); + IEnumerable GetDirectoryItems(string path); + string? ReadTextPreview(string path, int maxChars = 20000); + bool IsImage(string path); +} diff --git a/FileBrowser/ViewModels/MainViewModel.cs b/FileBrowser/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..01bbe05 --- /dev/null +++ b/FileBrowser/ViewModels/MainViewModel.cs @@ -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(); + CurrentItems = new ObservableCollection(); + + // 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 ParentItems { get; } = new ObservableCollection(); + public ObservableCollection CurrentItems { get; } = new ObservableCollection(); + + 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 parentItems; + try { parentItems = _fileService.GetDirectoryItems(parentForSiblings) ?? Array.Empty(); } + catch (Exception ex) { Debug.WriteLine("GetDirectoryItems(parent) failed: " + ex); parentItems = Array.Empty(); } + + foreach (var item in parentItems) ParentItems.Add(item); + + IEnumerable currentItems; + try { currentItems = _fileService.GetDirectoryItems(path) ?? Array.Empty(); } + catch (Exception ex) { Debug.WriteLine("GetDirectoryItems(current) failed: " + ex); currentItems = Array.Empty(); } + + 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 */ } + } + } +}