From 95bbd90bf2e8b4b0b5cd5603d356e380e43fab03 Mon Sep 17 00:00:00 2001 From: Melody Song Date: Thu, 7 May 2026 09:20:20 -0700 Subject: [PATCH 01/21] Add native code viewer with syntax highlighting and tree expansion Replaces WebView2/VS Code embed in RepoCodePage with a fully native WinUI 3 code viewer. Key changes: - Add ScintillaLexerDatabase.cs: maps 50+ language IDs to WinUIEdit HighlightingLanguage or Lexilla-direct lexers via SCI_SETLEXERLANGUAGE. Covers Python, Bash, Ruby, SQL, CSS, PowerShell, Lua, R, VB.NET, Diff, Makefile, Dockerfile, TOML, INI, Batch, Markdown, Perl, and C-like languages (Java, Go, Rust, Swift, Kotlin, Scala, TypeScript, PHP, Dart, F#, Elixir, Haskell, CMake) with correct keyword sets and VS Code Dark+/Light+ token colors. - Fix CodeEditorControl: ApplyLanguageId() uses database; ApplyFontSize() iterates all 128 styles without StyleClearAll() so token colors survive font changes; separate ApplyThemeColors() applies bg/fg/linenumber overrides independently. - Fix LanguageIdResolver: fallback changed from 'text' to 'plaintext' (valid WinUIEdit ID). - Fix tree expansion: switch RepoFileTreeView from ItemsSource-binding mode to TreeViewNode mode. Nodes are populated lazily in OnExpanding; HasUnrealizedChildren shows chevrons; no more E_NOINTERFACE crash. - Remove EditorAssetService, sync-vscode-assets.ps1, download-vsocde.ps1, eng/Sync-JitHubVsCodeAssets.ps1. - Add full renderer suite: CodePreview, MarkdownPreview, CsvPreview, JsonPreview, XmlPreview, YamlPreview, ImagePreview, SvgPreview, HexPreview, UnsupportedPreview. - Add LRU+disk cache (RepoFileCacheService), file preview resolver, repo tree service. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- JitHub.WinUI/App.xaml | 1 - JitHub.WinUI/App.xaml.cs | 8 +- .../Assets/CodeViewer/language-map.json | 255 ++++++ .../RepoContentNodeToMarkDownConverter.cs | 33 - JitHub.WinUI/JitHub.WinUI.csproj | 20 +- .../CodeViewer/FilePreviewDescriptor.cs | 7 + .../Models/CodeViewer/RepoFileBlob.cs | 10 + .../Models/CodeViewer/RepoFileCacheEntry.cs | 16 + .../Models/CodeViewer/RepoFileCacheKey.cs | 3 + .../Models/CodeViewer/RepoFilePreviewKind.cs | 16 + JitHub.WinUI/Models/CodeViewer/RepoTree.cs | 8 + .../Models/CodeViewer/RepoTreeNode.cs | 14 + .../GitHub/GitHubJsonSerializerContext.cs | 3 + JitHub.WinUI/Models/GitHub/GitHubTree.cs | 18 + JitHub.WinUI/Models/GitHub/GitHubTreeEntry.cs | 24 + .../CodeViewer/FilePreviewResolver.cs | 130 +++ .../CodeViewer/IFilePreviewResolver.cs | 9 + .../CodeViewer/ILanguageIdResolver.cs | 16 + .../CodeViewer/IRepoFileCacheService.cs | 13 + .../Services/CodeViewer/IRepoTreeService.cs | 21 + .../Services/CodeViewer/LanguageIdResolver.cs | 178 ++++ .../CodeViewer/RepoFileCacheService.cs | 435 ++++++++++ .../Services/CodeViewer/RepoTreeService.cs | 93 ++ JitHub.WinUI/Services/EditorAssetService.cs | 31 - JitHub.WinUI/Services/GitHubClientService.cs | 27 + JitHub.WinUI/Services/GitHubService.cs | 97 +++ JitHub.WinUI/Services/IGitHubClientService.cs | 8 + JitHub.WinUI/Services/IGitHubService.cs | 2 + .../CodeViewer/BreadcrumbSegment.cs | 4 + .../CodeViewer/RepoCodeBreadcrumbViewModel.cs | 97 +++ .../CodeViewer/RepoCodePageViewModel.cs | 351 ++++++++ .../CodeViewer/RepoFilePreviewViewModel.cs | 63 ++ .../CodeViewer/RepoFileTreeViewModel.cs | 185 ++++ .../CodeViewer/RepoTreeNodeViewModel.cs | 43 + .../CodeViewer/CodeEditorControl.xaml | 23 + .../CodeViewer/CodeEditorControl.xaml.cs | 467 ++++++++++ .../Controls/CodeViewer/FilePreviewHost.xaml | 51 ++ .../CodeViewer/FilePreviewHost.xaml.cs | 113 +++ .../CodeViewer/Renderers/CodePreview.xaml | 19 + .../CodeViewer/Renderers/CodePreview.xaml.cs | 25 + .../CodeViewer/Renderers/CsvPreview.xaml | 60 ++ .../CodeViewer/Renderers/CsvPreview.xaml.cs | 139 +++ .../CodeViewer/Renderers/HexPreview.xaml | 42 + .../CodeViewer/Renderers/HexPreview.xaml.cs | 102 +++ .../CodeViewer/Renderers/ImagePreview.xaml | 56 ++ .../CodeViewer/Renderers/ImagePreview.xaml.cs | 120 +++ .../CodeViewer/Renderers/JsonPreview.xaml | 46 + .../CodeViewer/Renderers/JsonPreview.xaml.cs | 88 ++ .../CodeViewer/Renderers/MarkdownPreview.xaml | 60 ++ .../Renderers/MarkdownPreview.xaml.cs | 49 ++ .../CodeViewer/Renderers/SvgPreview.xaml | 35 + .../CodeViewer/Renderers/SvgPreview.xaml.cs | 110 +++ .../Renderers/UnsupportedPreview.xaml | 62 ++ .../Renderers/UnsupportedPreview.xaml.cs | 69 ++ .../CodeViewer/Renderers/XmlPreview.xaml | 44 + .../CodeViewer/Renderers/XmlPreview.xaml.cs | 84 ++ .../CodeViewer/Renderers/YamlPreview.xaml | 44 + .../CodeViewer/Renderers/YamlPreview.xaml.cs | 87 ++ .../CodeViewer/RepoCodeBreadcrumb.xaml | 113 +++ .../CodeViewer/RepoCodeBreadcrumb.xaml.cs | 89 ++ .../Controls/CodeViewer/RepoFileTreeView.xaml | 104 +++ .../CodeViewer/RepoFileTreeView.xaml.cs | 151 ++++ .../CodeViewer/ScintillaLexerDatabase.cs | 804 ++++++++++++++++++ JitHub.WinUI/Views/Pages/RepoCodePage.xaml | 105 ++- JitHub.WinUI/Views/Pages/RepoCodePage.xaml.cs | 798 ++--------------- download-vsocde.ps1 | 30 - eng/Sync-JitHubVsCodeAssets.ps1 | 122 --- sync-vscode-assets.ps1 | 11 - 68 files changed, 5542 insertions(+), 1019 deletions(-) create mode 100644 JitHub.WinUI/Assets/CodeViewer/language-map.json delete mode 100644 JitHub.WinUI/Converters/RepoContentNodeToMarkDownConverter.cs create mode 100644 JitHub.WinUI/Models/CodeViewer/FilePreviewDescriptor.cs create mode 100644 JitHub.WinUI/Models/CodeViewer/RepoFileBlob.cs create mode 100644 JitHub.WinUI/Models/CodeViewer/RepoFileCacheEntry.cs create mode 100644 JitHub.WinUI/Models/CodeViewer/RepoFileCacheKey.cs create mode 100644 JitHub.WinUI/Models/CodeViewer/RepoFilePreviewKind.cs create mode 100644 JitHub.WinUI/Models/CodeViewer/RepoTree.cs create mode 100644 JitHub.WinUI/Models/CodeViewer/RepoTreeNode.cs create mode 100644 JitHub.WinUI/Models/GitHub/GitHubTree.cs create mode 100644 JitHub.WinUI/Models/GitHub/GitHubTreeEntry.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/FilePreviewResolver.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/IFilePreviewResolver.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/ILanguageIdResolver.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/IRepoFileCacheService.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/IRepoTreeService.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs create mode 100644 JitHub.WinUI/Services/CodeViewer/RepoTreeService.cs delete mode 100644 JitHub.WinUI/Services/EditorAssetService.cs create mode 100644 JitHub.WinUI/ViewModels/CodeViewer/BreadcrumbSegment.cs create mode 100644 JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs create mode 100644 JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs create mode 100644 JitHub.WinUI/ViewModels/CodeViewer/RepoFilePreviewViewModel.cs create mode 100644 JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs create mode 100644 JitHub.WinUI/ViewModels/CodeViewer/RepoTreeNodeViewModel.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/UnsupportedPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/UnsupportedPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/XmlPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/XmlPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml.cs create mode 100644 JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs delete mode 100644 download-vsocde.ps1 delete mode 100644 eng/Sync-JitHubVsCodeAssets.ps1 delete mode 100644 sync-vscode-assets.ps1 diff --git a/JitHub.WinUI/App.xaml b/JitHub.WinUI/App.xaml index 6986435..93522e2 100644 --- a/JitHub.WinUI/App.xaml +++ b/JitHub.WinUI/App.xaml @@ -164,7 +164,6 @@ - diff --git a/JitHub.WinUI/App.xaml.cs b/JitHub.WinUI/App.xaml.cs index 320316c..0177d26 100644 --- a/JitHub.WinUI/App.xaml.cs +++ b/JitHub.WinUI/App.xaml.cs @@ -6,6 +6,8 @@ using CommunityToolkit.Mvvm.DependencyInjection; using JitHub.Models; using JitHub.Services; +using JitHub.Services.CodeViewer; +using JitHub.WinUI.ViewModels.CodeViewer; using JitHub.WinUI.ViewModels.Pages; using JitHub.WinUI.Views.Pages; using JitHub.WinUI.Views.Pages.Design; @@ -158,7 +160,10 @@ private IServiceProvider BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddTransient(); @@ -171,6 +176,7 @@ private IServiceProvider BuildServices() services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); IServiceProvider serviceProvider = services.BuildServiceProvider(); Ioc.Default.ConfigureServices(serviceProvider); diff --git a/JitHub.WinUI/Assets/CodeViewer/language-map.json b/JitHub.WinUI/Assets/CodeViewer/language-map.json new file mode 100644 index 0000000..704df7b --- /dev/null +++ b/JitHub.WinUI/Assets/CodeViewer/language-map.json @@ -0,0 +1,255 @@ +{ + "extensions": { + ".cs": "csharp", + ".csx": "csharp", + ".c": "cpp", + ".h": "cpp", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".hh": "cpp", + ".java": "java", + ".kt": "kotlin", + ".kts": "kotlin", + ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".jsx": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".py": "python", + ".pyw": "python", + ".rb": "ruby", + ".rbw": "ruby", + ".rake": "ruby", + ".gemspec": "ruby", + ".go": "go", + ".rs": "rust", + ".php": "php", + ".php3": "php", + ".php4": "php", + ".php5": "php", + ".phtml": "php", + ".swift": "swift", + ".scala": "scala", + ".sc": "scala", + ".lua": "lua", + ".pl": "perl", + ".pm": "perl", + ".t": "perl", + ".sh": "bash", + ".bash": "bash", + ".zsh": "bash", + ".ksh": "bash", + ".fish": "bash", + ".ps1": "powershell", + ".psm1": "powershell", + ".psd1": "powershell", + ".bat": "batch", + ".cmd": "batch", + ".sql": "sql", + ".html": "html", + ".htm": "html", + ".xml": "xml", + ".xsd": "xml", + ".xslt": "xml", + ".xsl": "xml", + ".xhtml": "html", + ".css": "css", + ".scss": "scss", + ".sass": "scss", + ".less": "less", + ".vue": "html", + ".svelte": "html", + ".json": "json", + ".json5": "json", + ".jsonc": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".ini": "ini", + ".cfg": "ini", + ".conf": "ini", + ".properties": "ini", + ".env": "ini", + ".md": "markdown", + ".markdown": "markdown", + ".mdx": "markdown", + ".rst": "markdown", + ".tex": "latex", + ".bib": "latex", + ".sty": "latex", + ".cls": "latex", + ".csv": "text", + ".tsv": "text", + ".txt": "text", + ".log": "text", + ".plist": "xml", + ".dart": "dart", + ".r": "r", + ".rmd": "r", + ".fs": "fsharp", + ".fsx": "fsharp", + ".fsi": "fsharp", + ".vb": "vbnet", + ".bas": "vbnet", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".hs": "haskell", + ".lhs": "haskell", + ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".edn": "clojure", + ".ml": "ocaml", + ".mli": "ocaml", + ".scm": "lisp", + ".ss": "lisp", + ".lisp": "lisp", + ".cl": "lisp", + ".el": "lisp", + ".jl": "julia", + ".nim": "nim", + ".nims": "nim", + ".zig": "zig", + ".v": "verilog", + ".vhd": "vhdl", + ".vhdl": "vhdl", + ".sv": "verilog", + ".svh": "verilog", + ".tcl": "tcl", + ".asm": "asm", + ".s": "asm", + ".m": "objectivec", + ".mm": "objectivec", + ".groovy": "groovy", + ".gvy": "groovy", + ".gradle": "groovy", + ".cmake": "cmake", + ".dockerfile": "dockerfile", + ".makefile": "makefile", + ".mk": "makefile", + ".tf": "hcl", + ".hcl": "hcl", + ".proto": "protobuf", + ".graphql": "graphql", + ".gql": "graphql", + ".ipynb": "json", + ".vim": "vimscript", + ".vimrc": "vimscript", + ".lua": "lua", + ".asmx": "xml", + ".csproj": "xml", + ".vbproj": "xml", + ".fsproj": "xml", + ".proj": "xml", + ".props": "xml", + ".targets": "xml", + ".nuspec": "xml", + ".resx": "xml", + ".xaml": "xml", + ".svg": "xml", + ".wsdl": "xml", + ".htm": "html" + }, + "filenames": { + "Dockerfile": "dockerfile", + "Makefile": "makefile", + "GNUmakefile": "makefile", + "CMakeLists.txt": "cmake", + "Gemfile": "ruby", + "Gemfile.lock": "text", + "Rakefile": "ruby", + "Guardfile": "ruby", + "Podfile": "ruby", + ".gitignore": "ini", + ".gitattributes": "ini", + ".gitmodules": "ini", + ".editorconfig": "ini", + ".npmrc": "ini", + ".yarnrc": "ini", + ".babelrc": "json", + ".eslintrc": "json", + ".prettierrc": "json", + ".stylelintrc": "json", + ".huskyrc": "json", + ".lintstagedrc": "json", + "package.json": "json", + "package-lock.json": "json", + "yarn.lock": "text", + "tsconfig.json": "json", + "jsconfig.json": "json", + ".nvmrc": "text", + "Pipfile": "ini", + "Pipfile.lock": "json", + "requirements.txt": "text", + "setup.py": "python", + "setup.cfg": "ini", + "pyproject.toml": "toml", + "Cargo.toml": "toml", + "Cargo.lock": "toml", + "go.mod": "go", + "go.sum": "text", + "build.gradle": "groovy", + "settings.gradle": "groovy", + "pom.xml": "xml", + "build.xml": "xml", + "composer.json": "json", + "composer.lock": "json", + ".htaccess": "ini", + "nginx.conf": "ini", + "Vagrantfile": "ruby", + "Procfile": "text", + "Brewfile": "ruby", + "Fastfile": "ruby", + "Appfile": "ruby", + "LICENSE": "text", + "LICENSE.md": "markdown", + "LICENSE.txt": "text", + "README": "text", + "README.md": "markdown", + "README.txt": "text", + "CHANGELOG.md": "markdown", + "CONTRIBUTING.md": "markdown", + "CODEOWNERS": "text", + ".dockerignore": "ini", + "docker-compose.yml": "yaml", + "docker-compose.yaml": "yaml", + ".travis.yml": "yaml", + "appveyor.yml": "yaml", + ".circleci": "yaml" + }, + "interpreters": { + "python": "python", + "python2": "python", + "python3": "python", + "node": "javascript", + "nodejs": "javascript", + "deno": "javascript", + "bash": "bash", + "sh": "bash", + "zsh": "bash", + "ksh": "bash", + "fish": "bash", + "dash": "bash", + "pwsh": "powershell", + "powershell": "powershell", + "ruby": "ruby", + "perl": "perl", + "perl5": "perl", + "php": "php", + "lua": "lua", + "tclsh": "tcl", + "wish": "tcl", + "groovy": "groovy", + "java": "java", + "kotlin": "kotlin", + "scala": "scala", + "Rscript": "r", + "r": "r" + } +} diff --git a/JitHub.WinUI/Converters/RepoContentNodeToMarkDownConverter.cs b/JitHub.WinUI/Converters/RepoContentNodeToMarkDownConverter.cs deleted file mode 100644 index 4ec1940..0000000 --- a/JitHub.WinUI/Converters/RepoContentNodeToMarkDownConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using JitHub.Models; -using JitHub.Models.LegacyGitHub; -using System; -using System.Linq; -using Microsoft.UI.Xaml.Data; - -namespace JitHub.WinUI.Converters -{ - public partial class RepoContentNodeToMarkDownConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value == null) return string.Empty; - var node = value as RepoContentNode; - if (node is null || string.IsNullOrWhiteSpace(node.Name)) - { - return string.Empty; - } - - var splits = node.Name.Split("."); - var format = splits.Last(); - if (format.ToLower() == "md") return node.Content; - var code = String.Format("```{0}\n{1}\n```", format, node.Content); - return code; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => throw new NotSupportedException(); - } -} - - - diff --git a/JitHub.WinUI/JitHub.WinUI.csproj b/JitHub.WinUI/JitHub.WinUI.csproj index bfd3392..4c9a899 100644 --- a/JitHub.WinUI/JitHub.WinUI.csproj +++ b/JitHub.WinUI/JitHub.WinUI.csproj @@ -29,8 +29,6 @@ 1506;1510 false false - $(MSBuildProjectDirectory)\..\artifacts\EditorAssets\dist - $(EditorAssetsSourcePath)\index.html @@ -46,6 +44,7 @@ + @@ -70,12 +69,6 @@ PreserveNewest PreserveNewest - - Assets\dist\%(RecursiveDir)%(Filename)%(Extension) - PreserveNewest - PreserveNewest - false - @@ -101,6 +94,13 @@ + + + + + + + @@ -115,8 +115,4 @@ True True - - - - diff --git a/JitHub.WinUI/Models/CodeViewer/FilePreviewDescriptor.cs b/JitHub.WinUI/Models/CodeViewer/FilePreviewDescriptor.cs new file mode 100644 index 0000000..b47a177 --- /dev/null +++ b/JitHub.WinUI/Models/CodeViewer/FilePreviewDescriptor.cs @@ -0,0 +1,7 @@ +namespace JitHub.Models.CodeViewer; + +public sealed record FilePreviewDescriptor( + RepoFilePreviewKind Kind, + string LanguageId, + string? ImageMimeType, + bool IsLikelyBinary); diff --git a/JitHub.WinUI/Models/CodeViewer/RepoFileBlob.cs b/JitHub.WinUI/Models/CodeViewer/RepoFileBlob.cs new file mode 100644 index 0000000..6a68fbc --- /dev/null +++ b/JitHub.WinUI/Models/CodeViewer/RepoFileBlob.cs @@ -0,0 +1,10 @@ +namespace JitHub.Models.CodeViewer; + +public sealed class RepoFileBlob +{ + public string? Sha { get; init; } + public string? Encoding { get; init; } + public byte[]? Bytes { get; init; } + public string? Text { get; init; } + public bool IsBinary { get; init; } +} diff --git a/JitHub.WinUI/Models/CodeViewer/RepoFileCacheEntry.cs b/JitHub.WinUI/Models/CodeViewer/RepoFileCacheEntry.cs new file mode 100644 index 0000000..7aa8240 --- /dev/null +++ b/JitHub.WinUI/Models/CodeViewer/RepoFileCacheEntry.cs @@ -0,0 +1,16 @@ +using System; + +namespace JitHub.Models.CodeViewer; + +public sealed class RepoFileCacheEntry +{ + public required string Sha { get; init; } + public required long ByteLength { get; init; } + public required bool IsBinary { get; init; } + public required byte[] Bytes { get; init; } + /// Populated lazily for text files. + public string? Text { get; init; } + /// e.g. "utf-8", "base64" + public string? Encoding { get; init; } + public DateTimeOffset CachedAt { get; init; } +} diff --git a/JitHub.WinUI/Models/CodeViewer/RepoFileCacheKey.cs b/JitHub.WinUI/Models/CodeViewer/RepoFileCacheKey.cs new file mode 100644 index 0000000..161489e --- /dev/null +++ b/JitHub.WinUI/Models/CodeViewer/RepoFileCacheKey.cs @@ -0,0 +1,3 @@ +namespace JitHub.Models.CodeViewer; + +public readonly record struct RepoFileCacheKey(string Owner, string Repo, string Sha); diff --git a/JitHub.WinUI/Models/CodeViewer/RepoFilePreviewKind.cs b/JitHub.WinUI/Models/CodeViewer/RepoFilePreviewKind.cs new file mode 100644 index 0000000..ee2eb65 --- /dev/null +++ b/JitHub.WinUI/Models/CodeViewer/RepoFilePreviewKind.cs @@ -0,0 +1,16 @@ +namespace JitHub.Models.CodeViewer; + +public enum RepoFilePreviewKind +{ + Code, + Markdown, + Csv, + Json, + Xml, + Yaml, + Image, + Svg, + Hex, + Unsupported, + TooLarge, +} diff --git a/JitHub.WinUI/Models/CodeViewer/RepoTree.cs b/JitHub.WinUI/Models/CodeViewer/RepoTree.cs new file mode 100644 index 0000000..56d9349 --- /dev/null +++ b/JitHub.WinUI/Models/CodeViewer/RepoTree.cs @@ -0,0 +1,8 @@ +namespace JitHub.Models.CodeViewer; + +public sealed class RepoTree +{ + public string? Sha { get; init; } + public bool Truncated { get; init; } + public RepoTreeNode Root { get; init; } = new RepoTreeNode { Name = string.Empty, Path = string.Empty, IsDirectory = true }; +} diff --git a/JitHub.WinUI/Models/CodeViewer/RepoTreeNode.cs b/JitHub.WinUI/Models/CodeViewer/RepoTreeNode.cs new file mode 100644 index 0000000..636ac47 --- /dev/null +++ b/JitHub.WinUI/Models/CodeViewer/RepoTreeNode.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace JitHub.Models.CodeViewer; + +public sealed class RepoTreeNode +{ + public string Name { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public string? Sha { get; init; } + public long? Size { get; init; } + public bool IsDirectory { get; init; } + public string? ParentPath { get; init; } + public ICollection Children { get; init; } = new List(); +} diff --git a/JitHub.WinUI/Models/GitHub/GitHubJsonSerializerContext.cs b/JitHub.WinUI/Models/GitHub/GitHubJsonSerializerContext.cs index e90f4ed..3fcd82b 100644 --- a/JitHub.WinUI/Models/GitHub/GitHubJsonSerializerContext.cs +++ b/JitHub.WinUI/Models/GitHub/GitHubJsonSerializerContext.cs @@ -77,6 +77,9 @@ namespace JitHub.Models.GitHub; [JsonSerializable(typeof(GitHubRepositorySubscriptionRequest))] [JsonSerializable(typeof(GitHubRepositorySearchResponse))] [JsonSerializable(typeof(GitHubApiError))] +[JsonSerializable(typeof(GitHubTree))] +[JsonSerializable(typeof(GitHubTreeEntry))] +[JsonSerializable(typeof(GitHubTreeEntry[]))] internal partial class GitHubJsonSerializerContext : JsonSerializerContext { } diff --git a/JitHub.WinUI/Models/GitHub/GitHubTree.cs b/JitHub.WinUI/Models/GitHub/GitHubTree.cs new file mode 100644 index 0000000..f7949fc --- /dev/null +++ b/JitHub.WinUI/Models/GitHub/GitHubTree.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace JitHub.Models.GitHub; + +public sealed class GitHubTree +{ + [JsonPropertyName("sha")] + public string? Sha { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } + + [JsonPropertyName("tree")] + public GitHubTreeEntry[]? Tree { get; init; } + + [JsonPropertyName("truncated")] + public bool Truncated { get; init; } +} diff --git a/JitHub.WinUI/Models/GitHub/GitHubTreeEntry.cs b/JitHub.WinUI/Models/GitHub/GitHubTreeEntry.cs new file mode 100644 index 0000000..4cebfe7 --- /dev/null +++ b/JitHub.WinUI/Models/GitHub/GitHubTreeEntry.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace JitHub.Models.GitHub; + +public sealed class GitHubTreeEntry +{ + [JsonPropertyName("path")] + public string? Path { get; init; } + + [JsonPropertyName("mode")] + public string? Mode { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("sha")] + public string? Sha { get; init; } + + [JsonPropertyName("size")] + public long? Size { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } +} diff --git a/JitHub.WinUI/Services/CodeViewer/FilePreviewResolver.cs b/JitHub.WinUI/Services/CodeViewer/FilePreviewResolver.cs new file mode 100644 index 0000000..539a83f --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/FilePreviewResolver.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using JitHub.Models.CodeViewer; + +namespace JitHub.Services.CodeViewer; + +/// +/// Determines how a file should be rendered in the code viewer. +/// +public sealed class FilePreviewResolver : IFilePreviewResolver +{ + private const long MaxSizeBytes = 5 * 1024 * 1024; // 5 MB + private const long MaxHexBytes = 256 * 1024; // 256 KB + private const int BinarySniffLength = 8 * 1024; // 8 KB + private const double NonPrintableThreshold = 0.30; + + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", + ".tif", ".tiff", ".heic", ".heif", ".webp", + }; + + private static readonly HashSet MarkdownExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".md", ".markdown", ".mdx", + }; + + private readonly ILanguageIdResolver _languageResolver; + + public FilePreviewResolver(ILanguageIdResolver languageResolver) + { + _languageResolver = languageResolver; + } + + public FilePreviewDescriptor Resolve(string path, long byteSize, ReadOnlyMemory headSample) + { + // a) Size guard. + if (byteSize > MaxSizeBytes) + return new FilePreviewDescriptor(RepoFilePreviewKind.TooLarge, "text", null, false); + + string ext = Path.GetExtension(path); + + // b) Image extensions. + if (ImageExtensions.Contains(ext)) + { + string mime = GetImageMime(ext); + return new FilePreviewDescriptor(RepoFilePreviewKind.Image, "text", mime, true); + } + + // c) SVG. + if (string.Equals(ext, ".svg", StringComparison.OrdinalIgnoreCase)) + return new FilePreviewDescriptor(RepoFilePreviewKind.Svg, "xml", null, false); + + // d) Markdown. + if (MarkdownExtensions.Contains(ext)) + return new FilePreviewDescriptor(RepoFilePreviewKind.Markdown, "markdown", null, false); + + // e) CSV / TSV. + if (string.Equals(ext, ".csv", StringComparison.OrdinalIgnoreCase)) + return new FilePreviewDescriptor(RepoFilePreviewKind.Csv, "text", null, false); + if (string.Equals(ext, ".tsv", StringComparison.OrdinalIgnoreCase)) + return new FilePreviewDescriptor(RepoFilePreviewKind.Csv, "text", null, false); + + // f) JSON. + if (string.Equals(ext, ".json", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".json5", StringComparison.OrdinalIgnoreCase)) + return new FilePreviewDescriptor(RepoFilePreviewKind.Json, "json", null, false); + + // g) XML-family and HTML. + if (string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".xsd", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".xslt", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".html", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".htm", StringComparison.OrdinalIgnoreCase)) + return new FilePreviewDescriptor(RepoFilePreviewKind.Xml, "xml", null, false); + + // h) YAML. + if (string.Equals(ext, ".yml", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".yaml", StringComparison.OrdinalIgnoreCase)) + return new FilePreviewDescriptor(RepoFilePreviewKind.Yaml, "yaml", null, false); + + // i) Binary detection. + if (IsBinary(headSample)) + { + return byteSize <= MaxHexBytes + ? new FilePreviewDescriptor(RepoFilePreviewKind.Hex, "text", null, true) + : new FilePreviewDescriptor(RepoFilePreviewKind.Unsupported, "text", null, true); + } + + // j) Code — resolve language id. + string languageId = _languageResolver.Resolve(path, headSample.IsEmpty ? default : headSample.Span); + return new FilePreviewDescriptor(RepoFilePreviewKind.Code, languageId, null, false); + } + + private static bool IsBinary(ReadOnlyMemory sample) + { + if (sample.IsEmpty) return false; + + ReadOnlySpan span = sample.Length > BinarySniffLength + ? sample.Span.Slice(0, BinarySniffLength) + : sample.Span; + + int nonPrintable = 0; + foreach (byte b in span) + { + if (b == 0) return true; // null byte → definitely binary + if (b < 9 || (b > 13 && b < 32) || b == 127) + nonPrintable++; + } + + return (double)nonPrintable / span.Length > NonPrintableThreshold; + } + + private static string GetImageMime(string ext) => ext.ToLowerInvariant() switch + { + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".bmp" => "image/bmp", + ".ico" => "image/x-icon", + ".tif" => "image/tiff", + ".tiff" => "image/tiff", + ".heic" => "image/heif", + ".heif" => "image/heif", + ".webp" => "image/webp", + _ => "application/octet-stream", + }; +} diff --git a/JitHub.WinUI/Services/CodeViewer/IFilePreviewResolver.cs b/JitHub.WinUI/Services/CodeViewer/IFilePreviewResolver.cs new file mode 100644 index 0000000..7108d12 --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/IFilePreviewResolver.cs @@ -0,0 +1,9 @@ +using System; +using JitHub.Models.CodeViewer; + +namespace JitHub.Services.CodeViewer; + +public interface IFilePreviewResolver +{ + FilePreviewDescriptor Resolve(string path, long byteSize, ReadOnlyMemory headSample); +} diff --git a/JitHub.WinUI/Services/CodeViewer/ILanguageIdResolver.cs b/JitHub.WinUI/Services/CodeViewer/ILanguageIdResolver.cs new file mode 100644 index 0000000..8fecb15 --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/ILanguageIdResolver.cs @@ -0,0 +1,16 @@ +using System; + +namespace JitHub.Services.CodeViewer; + +public interface ILanguageIdResolver +{ + /// + /// Returns the WinUIEdit HighlightingLanguage id for the given file name. + /// Pass (first bytes of file) to enable shebang detection. + /// Falls back to "text". + /// + string Resolve(string fileName, ReadOnlySpan contentSniff = default); + + /// Returns true when the file name maps to a known language id. + bool IsKnown(string fileName); +} diff --git a/JitHub.WinUI/Services/CodeViewer/IRepoFileCacheService.cs b/JitHub.WinUI/Services/CodeViewer/IRepoFileCacheService.cs new file mode 100644 index 0000000..0f53716 --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/IRepoFileCacheService.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using JitHub.Models.CodeViewer; + +namespace JitHub.Services.CodeViewer; + +public interface IRepoFileCacheService +{ + bool TryGet(RepoFileCacheKey key, out RepoFileCacheEntry entry); + Task GetAsync(RepoFileCacheKey key, CancellationToken ct); + Task PutAsync(RepoFileCacheKey key, RepoFileCacheEntry entry, CancellationToken ct); + Task PurgeAsync(CancellationToken ct); +} diff --git a/JitHub.WinUI/Services/CodeViewer/IRepoTreeService.cs b/JitHub.WinUI/Services/CodeViewer/IRepoTreeService.cs new file mode 100644 index 0000000..c6e16ee --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/IRepoTreeService.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JitHub.Models; +using JitHub.Models.CodeViewer; + +namespace JitHub.Services.CodeViewer; + +public interface IRepoTreeService +{ + Task LoadTreeAsync(string owner, string name, string refOrSha, CancellationToken ct); + + Task> LoadDirectoryAsync( + string owner, + string name, + string path, + string refOrSha, + CancellationToken ct); + + Task LoadBlobAsync(string owner, string name, string sha, CancellationToken ct); +} diff --git a/JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs b/JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs new file mode 100644 index 0000000..3cd2794 --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JitHub.Services.CodeViewer; + +/// +/// Loads language-map.json at startup and resolves WinUIEdit HighlightingLanguage ids +/// by filename, extension, or shebang sniff. +/// +public sealed class LanguageIdResolver : ILanguageIdResolver +{ + private const string FallbackLanguage = "plaintext"; + + private readonly Dictionary _extensionMap; + private readonly Dictionary _filenameMap; + private readonly Dictionary _interpreterMap; + + public LanguageIdResolver() + { + string mapPath = Path.Combine(AppContext.BaseDirectory, "Assets", "CodeViewer", "language-map.json"); + + LanguageMapData? data = null; + try + { + using var stream = new FileStream(mapPath, FileMode.Open, FileAccess.Read, FileShare.Read); + data = JsonSerializer.Deserialize(stream, LanguageMapJsonContext.Default.LanguageMapData); + } + catch + { + // If the asset is missing at runtime, fall back to empty maps. + } + + _extensionMap = BuildCaseInsensitive(data?.Extensions); + _filenameMap = BuildCaseInsensitive(data?.Filenames); + _interpreterMap = BuildCaseInsensitive(data?.Interpreters); + } + + /// + public string Resolve(string fileName, ReadOnlySpan contentSniff = default) + { + if (string.IsNullOrEmpty(fileName)) + return FallbackLanguage; + + string baseName = Path.GetFileName(fileName); + + // 1. Exact filename match (case-insensitive). + if (_filenameMap.TryGetValue(baseName, out string? lang)) + return lang; + + // 2. Longest-matching extension. + string? extLang = ResolveByExtension(baseName); + if (extLang is not null) + return extLang; + + // 3. Shebang sniff — only when content provided and extension unknown. + if (!contentSniff.IsEmpty) + { + string? shebangLang = ResolveByShebang(contentSniff); + if (shebangLang is not null) + return shebangLang; + } + + return FallbackLanguage; + } + + /// + public bool IsKnown(string fileName) + { + if (string.IsNullOrEmpty(fileName)) + return false; + + string baseName = Path.GetFileName(fileName); + if (_filenameMap.ContainsKey(baseName)) + return true; + + return ResolveByExtension(baseName) is not null; + } + + private string? ResolveByExtension(string baseName) + { + // Try progressively shorter extensions, e.g. ".min.js" -> ".js" + ReadOnlySpan remaining = baseName.AsSpan(); + int dotIdx = remaining.IndexOf('.'); + if (dotIdx < 0) + return null; + + // Iterate from the first dot to get compound extensions handled longest-first. + string? bestLang = null; + while (true) + { + int nextDot = remaining.Slice(dotIdx).IndexOf('.'); + if (nextDot < 0) break; + + string ext = new string(remaining.Slice(dotIdx + nextDot)); + if (_extensionMap.TryGetValue(ext, out string? lang)) + { + bestLang = lang; + break; // longest match found + } + dotIdx += nextDot + 1; + if (dotIdx >= remaining.Length) break; + } + + if (bestLang is null) + { + // simple single-extension fallback + string simpleExt = Path.GetExtension(baseName); + if (!string.IsNullOrEmpty(simpleExt) && _extensionMap.TryGetValue(simpleExt, out string? sl)) + bestLang = sl; + } + + return bestLang; + } + + private string? ResolveByShebang(ReadOnlySpan bytes) + { + if (bytes.Length < 2 || bytes[0] != (byte)'#' || bytes[1] != (byte)'!') + return null; + + // Find end of first line. + int end = bytes.IndexOfAny((byte)'\n', (byte)'\r'); + ReadOnlySpan firstLine = end >= 0 ? bytes.Slice(2, end - 2) : bytes.Slice(2); + + string line = Encoding.UTF8.GetString(firstLine).Trim(); + + // Strip "/usr/bin/env " prefix. + const string envPrefix = "/usr/bin/env "; + if (line.StartsWith(envPrefix, StringComparison.Ordinal)) + line = line.Substring(envPrefix.Length).Trim(); + + // Take just the basename of the interpreter path. + int lastSlash = line.LastIndexOfAny(['/', '\\']); + if (lastSlash >= 0) + line = line.Substring(lastSlash + 1).Trim(); + + // Drop arguments. + int space = line.IndexOf(' '); + if (space >= 0) + line = line.Substring(0, space); + + if (string.IsNullOrEmpty(line)) + return null; + + return _interpreterMap.TryGetValue(line, out string? lang) ? lang : null; + } + + private static Dictionary BuildCaseInsensitive(Dictionary? source) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (source is null) return dict; + foreach (var (k, v) in source) + dict[k] = v; + return dict; + } +} + +// DTO for the JSON map file. +internal sealed class LanguageMapData +{ + [JsonPropertyName("extensions")] + public Dictionary? Extensions { get; init; } + + [JsonPropertyName("filenames")] + public Dictionary? Filenames { get; init; } + + [JsonPropertyName("interpreters")] + public Dictionary? Interpreters { get; init; } +} + +[JsonSerializable(typeof(LanguageMapData))] +[JsonSerializable(typeof(Dictionary))] +internal partial class LanguageMapJsonContext : JsonSerializerContext +{ +} diff --git a/JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs b/JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs new file mode 100644 index 0000000..a01352f --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs @@ -0,0 +1,435 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using JitHub.Models.CodeViewer; +using Windows.Storage; + +namespace JitHub.Services.CodeViewer; + +/// +/// Two-tier (in-memory LRU + disk) cache for repository file blobs. +/// +public sealed class RepoFileCacheService : IRepoFileCacheService +{ + // ── Configuration ──────────────────────────────────────────────────────── + private const int DefaultMemMaxEntries = 256; + private const long DefaultMemMaxBytes = 64L * 1024 * 1024; // 64 MB + private const long DefaultDiskMaxBytes = 256L * 1024 * 1024; // 256 MB + private static readonly TimeSpan DefaultTtl = TimeSpan.FromDays(7); + + private readonly int _memMaxEntries; + private readonly long _memMaxBytes; + private readonly long _diskMaxBytes; + private readonly TimeSpan _ttl; + private readonly string _diskRoot; + + // ── In-memory LRU ──────────────────────────────────────────────────────── + private long _memCurrentBytes; + private readonly LinkedList _lruList = new(); + private readonly Dictionary> _memIndex = new(StringComparer.Ordinal); + private readonly object _memLock = new(); + + // ── Concurrency ────────────────────────────────────────────────────────── + private readonly SemaphoreSlim _indexLock = new(1, 1); + private readonly ConcurrentDictionary _keyLocks = new(StringComparer.Ordinal); + + public RepoFileCacheService() + : this(DefaultMemMaxEntries, DefaultMemMaxBytes, DefaultDiskMaxBytes, DefaultTtl) { } + + public RepoFileCacheService(int memMaxEntries, long memMaxBytes, long diskMaxBytes, TimeSpan ttl) + { + _memMaxEntries = memMaxEntries; + _memMaxBytes = memMaxBytes; + _diskMaxBytes = diskMaxBytes; + _ttl = ttl; + + _diskRoot = Path.Combine(ApplicationData.Current.LocalCacheFolder.Path, "RepoFileCache"); + Directory.CreateDirectory(_diskRoot); + + // Background startup purge — do not block the constructor. + _ = Task.Run(() => PurgeAsync(CancellationToken.None)); + } + + // ── Public API ─────────────────────────────────────────────────────────── + + public bool TryGet(RepoFileCacheKey key, out RepoFileCacheEntry entry) + { + string mk = MemKey(key); + lock (_memLock) + { + if (_memIndex.TryGetValue(mk, out var node)) + { + // Promote to front (most-recently-used). + _lruList.Remove(node); + _lruList.AddFirst(node); + entry = node.Value.Entry; + return true; + } + } + entry = null!; + return false; + } + + public async Task GetAsync(RepoFileCacheKey key, CancellationToken ct) + { + if (TryGet(key, out var cached)) + return cached; + + var sem = GetKeyLock(key); + await sem.WaitAsync(ct).ConfigureAwait(false); + try + { + // Double-checked: another task may have populated memory while we waited. + if (TryGet(key, out var cached2)) + return cached2; + + string binPath = BinPath(key); + string metaPath = MetaPath(key); + + if (!File.Exists(binPath) || !File.Exists(metaPath)) + return null; + + DiskEntryMeta? meta; + { + await using var ms = new FileStream(metaPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); + meta = await JsonSerializer.DeserializeAsync(ms, RepoFileCacheJsonContext.Default.DiskEntryMeta, ct).ConfigureAwait(false); + } + + if (meta is null) + return null; + + if (DateTimeOffset.UtcNow - meta.CachedAt > _ttl) + { + DeleteDiskFiles(key); + return null; + } + + byte[] bytes = await File.ReadAllBytesAsync(binPath, ct).ConfigureAwait(false); + + var entry = BuildEntry(key.Sha, meta, bytes); + PromoteToMemory(MemKey(key), entry); + return entry; + } + finally + { + sem.Release(); + } + } + + public async Task PutAsync(RepoFileCacheKey key, RepoFileCacheEntry entry, CancellationToken ct) + { + PromoteToMemory(MemKey(key), entry); + + var sem = GetKeyLock(key); + await sem.WaitAsync(ct).ConfigureAwait(false); + try + { + string binPath = BinPath(key); + string metaPath = MetaPath(key); + Directory.CreateDirectory(Path.GetDirectoryName(binPath)!); + + await File.WriteAllBytesAsync(binPath, entry.Bytes, ct).ConfigureAwait(false); + + var meta = new DiskEntryMeta + { + ByteLength = entry.ByteLength, + IsBinary = entry.IsBinary, + Encoding = entry.Encoding, + CachedAt = entry.CachedAt == default ? DateTimeOffset.UtcNow : entry.CachedAt, + }; + + await _indexLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await using var ms = new FileStream(metaPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); + await JsonSerializer.SerializeAsync(ms, meta, RepoFileCacheJsonContext.Default.DiskEntryMeta, ct).ConfigureAwait(false); + + await AppendIndexEntryAsync(key, meta, ct).ConfigureAwait(false); + } + finally + { + _indexLock.Release(); + } + } + finally + { + sem.Release(); + } + + _ = Task.Run(() => EnforceDiskCapAsync(CancellationToken.None)); + } + + public async Task PurgeAsync(CancellationToken ct) + { + await PurgeExpiredAsync(ct).ConfigureAwait(false); + await EnforceDiskCapAsync(ct).ConfigureAwait(false); + } + + // ── Disk helpers ───────────────────────────────────────────────────────── + + private string SanitizedOwnerRepo(RepoFileCacheKey key) + { + char[] invalid = Path.GetInvalidFileNameChars(); + string raw = $"{key.Owner}_{key.Repo}"; + return string.Create(raw.Length, (raw, invalid), static (span, state) => + { + ReadOnlySpan src = state.raw.AsSpan(); + ReadOnlySpan inv = state.invalid.AsSpan(); + for (int i = 0; i < src.Length; i++) + span[i] = inv.Contains(src[i]) ? '_' : src[i]; + }); + } + + private string BinPath(RepoFileCacheKey key) + { + string sha = key.Sha; + string prefix = sha.Length >= 2 ? sha.Substring(0, 2) : sha; + return Path.Combine(_diskRoot, SanitizedOwnerRepo(key), prefix, sha + ".bin"); + } + + private string MetaPath(RepoFileCacheKey key) + { + string sha = key.Sha; + string prefix = sha.Length >= 2 ? sha.Substring(0, 2) : sha; + return Path.Combine(_diskRoot, SanitizedOwnerRepo(key), prefix, sha + ".json"); + } + + private string IndexPath() => Path.Combine(_diskRoot, "index.json"); + + private void DeleteDiskFiles(RepoFileCacheKey key) + { + try { File.Delete(BinPath(key)); } catch { } + try { File.Delete(MetaPath(key)); } catch { } + } + + // ── Index management ───────────────────────────────────────────────────── + + private async Task LoadIndexAsync(CancellationToken ct) + { + string path = IndexPath(); + if (!File.Exists(path)) + return new DiskCacheIndex(); + + try + { + await using var s = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); + return await JsonSerializer.DeserializeAsync(s, RepoFileCacheJsonContext.Default.DiskCacheIndex, ct).ConfigureAwait(false) + ?? new DiskCacheIndex(); + } + catch + { + return new DiskCacheIndex(); + } + } + + private async Task SaveIndexAsync(DiskCacheIndex index, CancellationToken ct) + { + string path = IndexPath(); + await using var s = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); + await JsonSerializer.SerializeAsync(s, index, RepoFileCacheJsonContext.Default.DiskCacheIndex, ct).ConfigureAwait(false); + } + + private async Task AppendIndexEntryAsync(RepoFileCacheKey key, DiskEntryMeta meta, CancellationToken ct) + { + // Already called under _indexLock. + var index = await LoadIndexAsync(ct).ConfigureAwait(false); + + // Remove any existing entry for this sha to avoid duplicates. + index.Entries.RemoveAll(e => string.Equals(e.Owner, key.Owner, StringComparison.OrdinalIgnoreCase) + && string.Equals(e.Repo, key.Repo, StringComparison.OrdinalIgnoreCase) + && string.Equals(e.Sha, key.Sha, StringComparison.OrdinalIgnoreCase)); + + index.Entries.Add(new DiskIndexEntry + { + Owner = key.Owner, + Repo = key.Repo, + Sha = key.Sha, + ByteLength = meta.ByteLength, + CachedAt = meta.CachedAt, + }); + + await SaveIndexAsync(index, ct).ConfigureAwait(false); + } + + private async Task PurgeExpiredAsync(CancellationToken ct) + { + await _indexLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var index = await LoadIndexAsync(ct).ConfigureAwait(false); + var cutoff = DateTimeOffset.UtcNow - _ttl; + + var expired = index.Entries.FindAll(e => e.CachedAt < cutoff); + foreach (var e in expired) + { + DeleteDiskFiles(new RepoFileCacheKey(e.Owner, e.Repo, e.Sha)); + index.Entries.Remove(e); + } + + if (expired.Count > 0) + await SaveIndexAsync(index, ct).ConfigureAwait(false); + } + catch { /* best-effort */ } + finally + { + _indexLock.Release(); + } + } + + private async Task EnforceDiskCapAsync(CancellationToken ct) + { + await _indexLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var index = await LoadIndexAsync(ct).ConfigureAwait(false); + long total = 0; + foreach (var e in index.Entries) total += e.ByteLength; + + if (total <= _diskMaxBytes) + return; + + // Sort by oldest first, evict until under cap. + index.Entries.Sort((a, b) => a.CachedAt.CompareTo(b.CachedAt)); + while (total > _diskMaxBytes && index.Entries.Count > 0) + { + var victim = index.Entries[0]; + index.Entries.RemoveAt(0); + total -= victim.ByteLength; + DeleteDiskFiles(new RepoFileCacheKey(victim.Owner, victim.Repo, victim.Sha)); + } + + await SaveIndexAsync(index, ct).ConfigureAwait(false); + } + catch { /* best-effort */ } + finally + { + _indexLock.Release(); + } + } + + // ── In-memory LRU helpers ───────────────────────────────────────────────── + + private void PromoteToMemory(string mk, RepoFileCacheEntry entry) + { + lock (_memLock) + { + if (_memIndex.TryGetValue(mk, out var existing)) + { + _lruList.Remove(existing); + _memCurrentBytes -= existing.Value.Entry.ByteLength; + _memIndex.Remove(mk); + } + + // Evict LRU entries until both caps are satisfied. + while ((_memIndex.Count >= _memMaxEntries || _memCurrentBytes + entry.ByteLength > _memMaxBytes) + && _lruList.Last is { } last) + { + _memCurrentBytes -= last.Value.Entry.ByteLength; + _memIndex.Remove(last.Value.Key); + _lruList.RemoveLast(); + } + + var node = new LinkedListNode(new MemoryLruEntry(mk, entry)); + _lruList.AddFirst(node); + _memIndex[mk] = node; + _memCurrentBytes += entry.ByteLength; + } + } + + private SemaphoreSlim GetKeyLock(RepoFileCacheKey key) + => _keyLocks.GetOrAdd(DiskKey(key), _ => new SemaphoreSlim(1, 1)); + + private static string MemKey(RepoFileCacheKey key) + => $"{key.Owner}/{key.Repo}/{key.Sha}"; + + private static string DiskKey(RepoFileCacheKey key) + => $"{key.Owner.ToLowerInvariant()}/{key.Repo.ToLowerInvariant()}/{key.Sha.ToLowerInvariant()}"; + + private static RepoFileCacheEntry BuildEntry(string sha, DiskEntryMeta meta, byte[] bytes) + { + string? text = meta.IsBinary ? null : TryDecodeText(bytes, meta.Encoding); + return new RepoFileCacheEntry + { + Sha = sha, + ByteLength = meta.ByteLength, + IsBinary = meta.IsBinary, + Bytes = bytes, + Text = text, + Encoding = meta.Encoding, + CachedAt = meta.CachedAt, + }; + } + + private static string? TryDecodeText(byte[] bytes, string? encoding) + { + try + { + var enc = string.Equals(encoding, "utf-8", StringComparison.OrdinalIgnoreCase) + ? Encoding.UTF8 + : Encoding.UTF8; + return enc.GetString(bytes); + } + catch + { + return null; + } + } + + // ── Nested types ────────────────────────────────────────────────────────── + + private sealed record MemoryLruEntry(string Key, RepoFileCacheEntry Entry); +} + +// ── JSON DTOs (disk) ────────────────────────────────────────────────────────── + +internal sealed class DiskEntryMeta +{ + [JsonPropertyName("byteLength")] + public long ByteLength { get; init; } + + [JsonPropertyName("isBinary")] + public bool IsBinary { get; init; } + + [JsonPropertyName("encoding")] + public string? Encoding { get; init; } + + [JsonPropertyName("cachedAt")] + public DateTimeOffset CachedAt { get; init; } +} + +internal sealed class DiskIndexEntry +{ + [JsonPropertyName("owner")] + public string Owner { get; set; } = string.Empty; + + [JsonPropertyName("repo")] + public string Repo { get; set; } = string.Empty; + + [JsonPropertyName("sha")] + public string Sha { get; set; } = string.Empty; + + [JsonPropertyName("byteLength")] + public long ByteLength { get; set; } + + [JsonPropertyName("cachedAt")] + public DateTimeOffset CachedAt { get; set; } +} + +internal sealed class DiskCacheIndex +{ + [JsonPropertyName("entries")] + public List Entries { get; init; } = new(); +} + +[JsonSerializable(typeof(DiskEntryMeta))] +[JsonSerializable(typeof(DiskIndexEntry))] +[JsonSerializable(typeof(DiskCacheIndex))] +internal partial class RepoFileCacheJsonContext : JsonSerializerContext +{ +} diff --git a/JitHub.WinUI/Services/CodeViewer/RepoTreeService.cs b/JitHub.WinUI/Services/CodeViewer/RepoTreeService.cs new file mode 100644 index 0000000..b25bad9 --- /dev/null +++ b/JitHub.WinUI/Services/CodeViewer/RepoTreeService.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using JitHub.Models; +using JitHub.Models.CodeViewer; +using JitHub.Models.LegacyGitHub; + +namespace JitHub.Services.CodeViewer; + +public sealed class RepoTreeService : IRepoTreeService +{ + private readonly IGitHubService _gitHubService; + + public RepoTreeService(IGitHubService gitHubService) + { + _gitHubService = gitHubService; + } + + public Task LoadTreeAsync(string owner, string name, string refOrSha, CancellationToken ct) + { + return _gitHubService.GetRepoTreeAsync(owner, name, refOrSha, ct); + } + + public async Task> LoadDirectoryAsync( + string owner, + string name, + string path, + string refOrSha, + CancellationToken ct) + { + ICollection nodes = await _gitHubService.GetRepoContents(owner, name, path, refOrSha); + return nodes is IReadOnlyList list ? list : nodes.ToList(); + } + + public async Task LoadBlobAsync(string owner, string name, string sha, CancellationToken ct) + { + Blob blob = await _gitHubService.GetBlocFromGit(owner, name, sha); + + byte[] bytes = await Task.Run(() => DecodeBlob(blob.Content, blob.Encoding.Value), ct); + + bool isBinary = IsBinaryContent(bytes); + string? text = isBinary ? null : DecodeText(bytes); + + return new RepoFileBlob + { + Sha = blob.Sha, + Encoding = blob.Encoding.StringValue, + Bytes = bytes, + Text = text, + IsBinary = isBinary, + }; + } + + private static byte[] DecodeBlob(string? content, EncodingType encoding) + { + if (string.IsNullOrEmpty(content)) + return Array.Empty(); + + if (encoding == EncodingType.Base64) + { + string normalized = content.Replace("\r", string.Empty).Replace("\n", string.Empty); + return Convert.FromBase64String(normalized); + } + + return Encoding.UTF8.GetBytes(content); + } + + private static bool IsBinaryContent(byte[] bytes) + { + int scanLength = Math.Min(bytes.Length, 8192); + for (int i = 0; i < scanLength; i++) + { + if (bytes[i] == 0) + return true; + } + return false; + } + + private static string? DecodeText(byte[] bytes) + { + try + { + return Encoding.UTF8.GetString(bytes); + } + catch + { + return null; + } + } +} diff --git a/JitHub.WinUI/Services/EditorAssetService.cs b/JitHub.WinUI/Services/EditorAssetService.cs deleted file mode 100644 index 914ec6a..0000000 --- a/JitHub.WinUI/Services/EditorAssetService.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; - -namespace JitHub.Services; - -public sealed class EditorAssetService -{ - private string? _resolvedEditorRootPath; - - public Task GetEditorRootPathAsync() - { - if (!string.IsNullOrWhiteSpace(_resolvedEditorRootPath) && - File.Exists(Path.Combine(_resolvedEditorRootPath, "index.html"))) - { - return Task.FromResult(_resolvedEditorRootPath); - } - - string editorRootPath = Path.Combine(AppContext.BaseDirectory, "Assets", "dist"); - string editorIndexPath = Path.Combine(editorRootPath, "index.html"); - if (!File.Exists(editorIndexPath)) - { - throw new FileNotFoundException( - "Embedded editor assets were not found. Run .\\sync-vscode-assets.ps1 before building, publishing, or debugging JitHub.WinUI.", - editorIndexPath); - } - - _resolvedEditorRootPath = editorRootPath; - return Task.FromResult(editorRootPath); - } -} diff --git a/JitHub.WinUI/Services/GitHubClientService.cs b/JitHub.WinUI/Services/GitHubClientService.cs index 1dae84b..61e1796 100644 --- a/JitHub.WinUI/Services/GitHubClientService.cs +++ b/JitHub.WinUI/Services/GitHubClientService.cs @@ -1620,6 +1620,33 @@ public async Task> SearchRepositoriesAsync( .ToList(); } + public async Task GetTreeAsync( + string token, + string owner, + string name, + string treeSha, + bool recursive, + CancellationToken cancellationToken = default) + { + string path = + $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}/git/trees/{Uri.EscapeDataString(treeSha)}"; + if (recursive) + { + path += "?recursive=1"; + } + + using HttpRequestMessage request = CreateAuthenticatedRequest(HttpMethod.Get, path, token); + using HttpResponseMessage response = + await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + + return await ReadResponseAsync( + response, + GitHubJsonSerializerContext.Default.GitHubTree, + "git tree", + cancellationToken); + } + private static HttpRequestMessage CreateAuthenticatedRequest(HttpMethod method, string relativePath, string token) { HttpRequestMessage request = new(method, relativePath); diff --git a/JitHub.WinUI/Services/GitHubService.cs b/JitHub.WinUI/Services/GitHubService.cs index 104f27a..47f19f0 100644 --- a/JitHub.WinUI/Services/GitHubService.cs +++ b/JitHub.WinUI/Services/GitHubService.cs @@ -23,6 +23,7 @@ using System.Linq; using System.Runtime.InteropServices.WindowsRuntime; using System.Text; +using System.Threading; using System.Threading.Tasks; using Windows.Storage.Streams; using Microsoft.UI.Xaml.Controls; @@ -1534,6 +1535,102 @@ public async Task> GetRepoContents(string owner, st return nodes; } + public async Task GetRepoTreeAsync(string owner, string name, string refOrSha, CancellationToken ct) + { + Models.GitHub.GitHubTree gitTree = await _gitHubClientService.GetTreeAsync( + GetAccessTokenOrThrow(), + owner, + name, + refOrSha, + recursive: true, + ct); + return BuildRepoTree(gitTree); + } + + private static JitHub.Models.CodeViewer.RepoTree BuildRepoTree(Models.GitHub.GitHubTree gitTree) + { + var root = new JitHub.Models.CodeViewer.RepoTreeNode + { + Name = string.Empty, + Path = string.Empty, + IsDirectory = true, + ParentPath = null, + }; + + // index of path -> node for fast lookup while building tree + var nodeMap = new Dictionary(StringComparer.Ordinal) + { + [string.Empty] = root + }; + + if (gitTree.Tree is not null) + { + foreach (var entry in gitTree.Tree) + { + if (string.IsNullOrEmpty(entry.Path)) + continue; + + EnsurePath(entry.Path, entry, nodeMap); + } + } + + // Sort each directory's children: directories first, then files, each group alphabetical + SortChildren(root); + + return new JitHub.Models.CodeViewer.RepoTree + { + Sha = gitTree.Sha, + Truncated = gitTree.Truncated, + Root = root, + }; + } + + private static JitHub.Models.CodeViewer.RepoTreeNode EnsurePath( + string path, + Models.GitHub.GitHubTreeEntry? entry, + Dictionary nodeMap) + { + if (nodeMap.TryGetValue(path, out var existing)) + return existing; + + int slashIndex = path.LastIndexOf('/'); + string parentPath = slashIndex < 0 ? string.Empty : path[..slashIndex]; + string name = slashIndex < 0 ? path : path[(slashIndex + 1)..]; + + JitHub.Models.CodeViewer.RepoTreeNode parent = EnsurePath(parentPath, null, nodeMap); + + bool isDir = entry is null || string.Equals(entry.Type, "tree", StringComparison.Ordinal); + var node = new JitHub.Models.CodeViewer.RepoTreeNode + { + Name = name, + Path = path, + Sha = entry?.Sha, + Size = entry?.Size, + IsDirectory = isDir, + ParentPath = parentPath, + }; + + nodeMap[path] = node; + ((List)parent.Children).Add(node); + return node; + } + + private static void SortChildren(JitHub.Models.CodeViewer.RepoTreeNode node) + { + var list = (List)node.Children; + list.Sort(static (a, b) => + { + if (a.IsDirectory != b.IsDirectory) + return a.IsDirectory ? -1 : 1; + return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); + }); + foreach (var child in list) + { + if (child.IsDirectory) + SortChildren(child); + } + } + public async Task CompareCommits(string owner, string name, string @base, string head) { return AdaptCompareResult(await _gitHubClientService.CompareCommitsAsync(GetAccessTokenOrThrow(), owner, name, @base, head)); diff --git a/JitHub.WinUI/Services/IGitHubClientService.cs b/JitHub.WinUI/Services/IGitHubClientService.cs index 1787e28..1140bdc 100644 --- a/JitHub.WinUI/Services/IGitHubClientService.cs +++ b/JitHub.WinUI/Services/IGitHubClientService.cs @@ -492,4 +492,12 @@ Task> SearchRepositoriesAsync( int pageSize, int pageNumber = 1, CancellationToken cancellationToken = default); + + Task GetTreeAsync( + string token, + string owner, + string name, + string treeSha, + bool recursive, + CancellationToken cancellationToken = default); } diff --git a/JitHub.WinUI/Services/IGitHubService.cs b/JitHub.WinUI/Services/IGitHubService.cs index f369ccd..a24dd6e 100644 --- a/JitHub.WinUI/Services/IGitHubService.cs +++ b/JitHub.WinUI/Services/IGitHubService.cs @@ -17,6 +17,7 @@ using RepositoryIssueRequest = JitHub.Models.LegacyGitHub.RepositoryIssueRequest; using SearchRepositoriesRequest = JitHub.Models.LegacyGitHub.SearchRepositoriesRequest; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace JitHub.Services @@ -41,6 +42,7 @@ public interface IGitHubService Task GetFileContent(string owner, string name, string path, string _ref); Task GetBlocFromGit(string owner, string name, string _ref); Task> GetRepoContents(string owner, string name, string path, string _ref); + Task GetRepoTreeAsync(string owner, string name, string refOrSha, CancellationToken ct); Task CompareCommits(string owner, string name, string @base, string head); Task> GetRepoBranches(string owner, string name); Task GetBranch(string owner, string name, string branch); diff --git a/JitHub.WinUI/ViewModels/CodeViewer/BreadcrumbSegment.cs b/JitHub.WinUI/ViewModels/CodeViewer/BreadcrumbSegment.cs new file mode 100644 index 0000000..ff19558 --- /dev/null +++ b/JitHub.WinUI/ViewModels/CodeViewer/BreadcrumbSegment.cs @@ -0,0 +1,4 @@ +namespace JitHub.WinUI.ViewModels.CodeViewer; + +/// One segment in the breadcrumb path (e.g. repo root, folder, file). +public sealed record BreadcrumbSegment(string Label, string Path, bool IsRoot); diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs new file mode 100644 index 0000000..2c22327 --- /dev/null +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Windows.ApplicationModel.DataTransfer; +using Windows.System; + +namespace JitHub.WinUI.ViewModels.CodeViewer; + +public sealed partial class RepoCodeBreadcrumbViewModel : ObservableObject +{ + public ObservableCollection Segments { get; } = []; + + [ObservableProperty] + public partial string? CurrentRawUrl { get; set; } + + [ObservableProperty] + public partial string? CurrentGitHubUrl { get; set; } + + /// + /// Optional callback invoked when the user taps a breadcrumb segment. + /// The page VM wires this to expand the tree to that folder. + /// + public Action? OnNavigate { get; set; } + + [RelayCommand] + private async System.Threading.Tasks.Task NavigateToSegmentAsync(BreadcrumbSegment? segment) + { + if (segment is not null) + { + OnNavigate?.Invoke(segment); + } + await System.Threading.Tasks.Task.CompletedTask; + } + + [RelayCommand] + private async System.Threading.Tasks.Task CopyPathAsync() + { + string? path = GetCurrentFilePath(); + if (path is null) return; + + var dp = new DataPackage(); + dp.SetText(path); + Clipboard.SetContent(dp); + await System.Threading.Tasks.Task.CompletedTask; + } + + [RelayCommand] + private async System.Threading.Tasks.Task CopyRawUrlAsync() + { + if (CurrentRawUrl is null) return; + + var dp = new DataPackage(); + dp.SetText(CurrentRawUrl); + Clipboard.SetContent(dp); + await System.Threading.Tasks.Task.CompletedTask; + } + + [RelayCommand] + private async System.Threading.Tasks.Task OpenOnGitHubAsync() + { + if (CurrentGitHubUrl is not null && Uri.TryCreate(CurrentGitHubUrl, UriKind.Absolute, out Uri? uri)) + { + await Launcher.LaunchUriAsync(uri); + } + } + + /// + /// Rebuilds segments from a file path (e.g. "src/foo/Bar.cs"). + /// The first segment is always the repo root with as label. + /// + internal void BuildFromPath(string repoName, string filePath) + { + Segments.Clear(); + Segments.Add(new BreadcrumbSegment(repoName, string.Empty, IsRoot: true)); + + if (string.IsNullOrEmpty(filePath)) return; + + string[] parts = filePath.Split('/'); + string accumulated = string.Empty; + foreach (string part in parts) + { + accumulated = accumulated.Length == 0 ? part : accumulated + "/" + part; + Segments.Add(new BreadcrumbSegment(part, accumulated, IsRoot: false)); + } + } + + private string? GetCurrentFilePath() + { + // The last non-root segment is the current file/folder path. + for (int i = Segments.Count - 1; i >= 0; i--) + { + if (!Segments[i].IsRoot) return Segments[i].Path; + } + return null; + } +} diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs new file mode 100644 index 0000000..43ef193 --- /dev/null +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using JitHub.Models.CodeViewer; +using JitHub.Services; +using JitHub.Services.CodeViewer; +using Microsoft.UI.Dispatching; + +namespace JitHub.WinUI.ViewModels.CodeViewer; + +public sealed partial class RepoCodePageViewModel : ObservableObject +{ + private readonly IGitHubService _github; + private readonly IRepoTreeService _treeService; + private readonly IRepoFileCacheService _cache; + private readonly IFilePreviewResolver _previewResolver; + private readonly ILanguageIdResolver _languageResolver; + private readonly DispatcherQueue _dispatcherQueue; + + // Navigation state + private string _owner = string.Empty; + private string _repositoryName = string.Empty; + private string _ref = string.Empty; + + // Back/forward stacks + private readonly List _backStack = []; + private readonly List _forwardStack = []; + + // Per-selection cancellation + private CancellationTokenSource? _selectionCts; + + public string Owner => _owner; + public string RepositoryName => _repositoryName; + public string Ref => _ref; + + public RepoFileTreeViewModel Tree { get; } + public RepoFilePreviewViewModel Preview { get; } + public RepoCodeBreadcrumbViewModel Breadcrumb { get; } + + [ObservableProperty] + public partial bool IsLoading { get; set; } + + [ObservableProperty] + public partial string? LoadError { get; set; } + + [ObservableProperty] + public partial bool CanGoBack { get; set; } + + [ObservableProperty] + public partial bool CanGoForward { get; set; } + + public RepoCodePageViewModel( + IGitHubService github, + IRepoTreeService treeService, + IRepoFileCacheService cache, + IFilePreviewResolver previewResolver, + ILanguageIdResolver languageResolver) + { + _github = github; + _treeService = treeService; + _cache = cache; + _previewResolver = previewResolver; + _languageResolver = languageResolver; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + + Tree = new RepoFileTreeViewModel(treeService, languageResolver); + Preview = new RepoFilePreviewViewModel(); + Breadcrumb = new RepoCodeBreadcrumbViewModel(); + + // Wire tree selection to page-level handler. + Tree.OnSelectNode = (node, ct) => SelectFileAsync(node.IsDirectory ? null! : ToModelNode(node), ct); + Breadcrumb.OnNavigate = _ => { /* tree expansion handled by view */ }; + + // InitializeCommand (no-arg): re-triggers initialization with currently stored owner/name/ref. + InitializeCommand = new AsyncRelayCommand( + () => InitializeAsync(_owner, _repositoryName, _ref, CancellationToken.None)); + SelectFileCommand = new AsyncRelayCommand( + node => SelectFileAsync(node!, CancellationToken.None)); + GoBackCommand = new RelayCommand(GoBack, () => CanGoBack); + GoForwardCommand = new RelayCommand(GoForward, () => CanGoForward); + } + + // Exposed commands (named exactly as in the contract). + public AsyncRelayCommand InitializeCommand { get; } + public AsyncRelayCommand SelectFileCommand { get; } + public RelayCommand GoBackCommand { get; } + public RelayCommand GoForwardCommand { get; } + + public async Task InitializeAsync(string owner, string name, string @ref, CancellationToken ct) + { + _owner = owner; + _repositoryName = name; + _ref = @ref; + + _backStack.Clear(); + _forwardStack.Clear(); + UpdateNavigation(); + Preview.Reset(); + + RunOnUi(() => + { + IsLoading = true; + LoadError = null; + Tree.IsLoading = true; + }); + + try + { + RepoTree tree = await _treeService.LoadTreeAsync(owner, name, @ref, ct); + RunOnUi(() => + { + Tree.Load(tree, owner, name, @ref); + Tree.IsTruncated = tree.Truncated; + Tree.IsLoading = false; + IsLoading = false; + }); + } + catch (OperationCanceledException) + { + RunOnUi(() => + { + Tree.IsLoading = false; + IsLoading = false; + }); + } + catch (Exception ex) + { + RunOnUi(() => + { + Tree.IsLoading = false; + IsLoading = false; + LoadError = ex.Message; + }); + } + } + + public async Task SelectFileAsync(RepoTreeNode? node, CancellationToken ct) + { + if (node is null || node.IsDirectory) return; + + // Cancel any in-flight selection. + _selectionCts?.Cancel(); + _selectionCts?.Dispose(); + _selectionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + await SelectFileAsyncInternal(node, _selectionCts.Token, push: true); + } + + private void GoBack() + { + if (_backStack.Count <= 1) return; + + // Pop current (last) from back, push to forward. + RepoTreeNode current = _backStack[^1]; + _backStack.RemoveAt(_backStack.Count - 1); + _forwardStack.Add(current); + UpdateNavigation(); + + if (_backStack.Count > 0) + { + RepoTreeNode target = _backStack[^1]; + _ = NavigateWithoutStackPushAsync(target); + } + } + + private void GoForward() + { + if (_forwardStack.Count == 0) return; + + RepoTreeNode target = _forwardStack[^1]; + _forwardStack.RemoveAt(_forwardStack.Count - 1); + _backStack.Add(target); + UpdateNavigation(); + _ = NavigateWithoutStackPushAsync(target); + } + + private async Task NavigateWithoutStackPushAsync(RepoTreeNode node) + { + // Navigate without pushing onto back stack again. + _selectionCts?.Cancel(); + _selectionCts?.Dispose(); + _selectionCts = new CancellationTokenSource(); + + // Temporarily detach the push logic by using the internal select logic. + // We reuse SelectFileAsync but intercept the push at the end. + await SelectFileAsyncInternal(node, _selectionCts.Token, push: false); + } + + private void PushBackStack(RepoTreeNode node) + { + // Avoid duplicate top entry. + if (_backStack.Count > 0 && _backStack[^1].Path == node.Path) return; + + _backStack.Add(node); + _forwardStack.Clear(); + UpdateNavigation(); + } + + private void UpdateNavigation() + { + CanGoBack = _backStack.Count > 1; + CanGoForward = _forwardStack.Count > 0; + GoBackCommand.NotifyCanExecuteChanged(); + GoForwardCommand.NotifyCanExecuteChanged(); + } + + /// + /// Core implementation shared by SelectFileAsync and back/forward navigation. + /// + private async Task SelectFileAsyncInternal(RepoTreeNode node, CancellationToken token, bool push) + { + if (node.IsDirectory) return; + + RunOnUi(() => + { + Preview.IsLoading = true; + Preview.ErrorMessage = null; + Preview.CurrentFile = node; + }); + + try + { + byte[] bytes; + string? text; + string? encoding; + long byteSize; + + var cacheKey = new RepoFileCacheKey(_owner, _repositoryName, node.Sha ?? string.Empty); + if (_cache.TryGet(cacheKey, out RepoFileCacheEntry cached)) + { + bytes = cached.Bytes; + text = cached.Text; + encoding = cached.Encoding; + byteSize = cached.ByteLength; + } + else + { + RepoFileCacheEntry? asyncCached = await _cache.GetAsync(cacheKey, token); + if (asyncCached is not null) + { + bytes = asyncCached.Bytes; + text = asyncCached.Text; + encoding = asyncCached.Encoding; + byteSize = asyncCached.ByteLength; + } + else + { + RepoFileBlob blob = await _treeService.LoadBlobAsync(_owner, _repositoryName, node.Sha ?? string.Empty, token); + bytes = blob.Bytes ?? []; + text = blob.Text; + encoding = blob.Encoding; + byteSize = bytes.LongLength; + + if (text is null && !blob.IsBinary && bytes.Length > 0) + { + text = await Task.Run(() => Encoding.UTF8.GetString(bytes), token); + } + + var entry = new RepoFileCacheEntry + { + Sha = node.Sha ?? string.Empty, + ByteLength = byteSize, + IsBinary = blob.IsBinary, + Bytes = bytes, + Text = text, + Encoding = encoding, + CachedAt = DateTimeOffset.UtcNow, + }; + await _cache.PutAsync(cacheKey, entry, token); + } + } + + int sniffLen = (int)Math.Min(bytes.LongLength, 8192L); + ReadOnlyMemory headSample = bytes.AsMemory(0, sniffLen); + FilePreviewDescriptor descriptor = _previewResolver.Resolve(node.Path, byteSize, headSample); + + string gitHubUrl = $"https://github.com/{_owner}/{_repositoryName}/blob/{_ref}/{node.Path}"; + string rawUrl = $"https://raw.githubusercontent.com/{_owner}/{_repositoryName}/{_ref}/{node.Path}"; + + RunOnUi(() => + { + Preview.Kind = descriptor.Kind; + Preview.LanguageId = descriptor.LanguageId; + Preview.ByteSize = byteSize; + Preview.Encoding = encoding; + Preview.ImageMimeType = descriptor.ImageMimeType; + + if (descriptor.Kind is RepoFilePreviewKind.TooLarge or RepoFilePreviewKind.Unsupported) + { + Preview.GitHubBlobUrl = gitHubUrl; + Preview.Text = null; + Preview.Bytes = null; + } + else if (descriptor.IsLikelyBinary) + { + Preview.Bytes = bytes; + Preview.Text = null; + } + else + { + Preview.Text = text; + Preview.Bytes = bytes; + } + + Preview.IsLoading = false; + + Breadcrumb.BuildFromPath(_repositoryName, node.Path); + Breadcrumb.CurrentRawUrl = rawUrl; + Breadcrumb.CurrentGitHubUrl = gitHubUrl; + }); + + if (push) PushBackStack(node); + } + catch (OperationCanceledException) + { + RunOnUi(() => Preview.IsLoading = false); + } + catch (Exception ex) + { + RunOnUi(() => + { + Preview.IsLoading = false; + Preview.ErrorMessage = ex.Message; + }); + } + } + + private void RunOnUi(Action action) + { + if (_dispatcherQueue is null || _dispatcherQueue.HasThreadAccess) + { + action(); + } + else + { + _dispatcherQueue.TryEnqueue(() => action()); + } + } + + private static RepoTreeNode ToModelNode(RepoTreeNodeViewModel vm) => new() + { + Name = vm.Name, + Path = vm.Path, + Sha = vm.Sha, + Size = vm.Size, + IsDirectory = vm.IsDirectory, + }; +} diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoFilePreviewViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoFilePreviewViewModel.cs new file mode 100644 index 0000000..314d56d --- /dev/null +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoFilePreviewViewModel.cs @@ -0,0 +1,63 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using JitHub.Models.CodeViewer; + +namespace JitHub.WinUI.ViewModels.CodeViewer; + +public sealed partial class RepoFilePreviewViewModel : ObservableObject +{ + [ObservableProperty] + public partial RepoTreeNode? CurrentFile { get; set; } + + [ObservableProperty] + public partial RepoFilePreviewKind Kind { get; set; } + + [ObservableProperty] + public partial string LanguageId { get; set; } = string.Empty; + + [ObservableProperty] + public partial string? Text { get; set; } + + [ObservableProperty] + public partial byte[]? Bytes { get; set; } + + [ObservableProperty] + public partial string? ImageMimeType { get; set; } + + [ObservableProperty] + public partial long ByteSize { get; set; } + + [ObservableProperty] + public partial string? Encoding { get; set; } + + [ObservableProperty] + public partial bool IsLoading { get; set; } + + [ObservableProperty] + public partial string? ErrorMessage { get; set; } + + [ObservableProperty] + public partial string? GitHubBlobUrl { get; set; } + + [ObservableProperty] + public partial bool ShowRichPreview { get; set; } = true; + + [RelayCommand] + private void ToggleRichPreview() => ShowRichPreview = !ShowRichPreview; + + internal void Reset() + { + CurrentFile = null; + Kind = RepoFilePreviewKind.Code; + LanguageId = string.Empty; + Text = null; + Bytes = null; + ImageMimeType = null; + ByteSize = 0; + Encoding = null; + IsLoading = false; + ErrorMessage = null; + GitHubBlobUrl = null; + ShowRichPreview = true; + } +} diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs new file mode 100644 index 0000000..be802fb --- /dev/null +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using JitHub.Models.CodeViewer; +using JitHub.Services.CodeViewer; + +namespace JitHub.WinUI.ViewModels.CodeViewer; + +public sealed partial class RepoFileTreeViewModel : ObservableObject +{ + private readonly IRepoTreeService _treeService; + private readonly ILanguageIdResolver _languageResolver; + + // Owner/repo/ref stored for the truncated-tree directory load fallback. + private string _owner = string.Empty; + private string _repo = string.Empty; + private string _ref = string.Empty; + + public ObservableCollection RootNodes { get; } = []; + + [ObservableProperty] + public partial RepoTreeNodeViewModel? SelectedNode { get; set; } + + [ObservableProperty] + public partial bool IsTruncated { get; set; } + + [ObservableProperty] + public partial bool IsLoading { get; set; } + + [ObservableProperty] + public partial string FilterText { get; set; } = string.Empty; + + /// + /// Filtered view of RootNodes by FilterText (case-insensitive substring match on Path). + /// Updated whenever FilterText changes. + /// + public IEnumerable FilteredRootNodes => _filteredRootNodes; + + private ObservableCollection _filteredRootNodes = []; + + // Callback wired by page VM so SelectNodeCommand routes to it. + public Func? OnSelectNode { get; set; } + + public RepoFileTreeViewModel(IRepoTreeService treeService, ILanguageIdResolver languageResolver) + { + _treeService = treeService; + _languageResolver = languageResolver; + } + + partial void OnFilterTextChanged(string value) + { + RebuildFilter(); + } + + [RelayCommand] + private async Task ToggleExpandAsync(RepoTreeNodeViewModel? node, CancellationToken ct) + { + if (node is null) return; + + if (!node.IsExpanded) + { + // Expand: load children if needed (truncated fallback). + if (!node.ChildrenLoaded && node.IsDirectory) + { + await LoadDirectoryAsync(node, ct); + } + node.IsExpanded = true; + } + else + { + node.IsExpanded = false; + } + } + + [RelayCommand] + private async Task SelectNodeAsync(RepoTreeNodeViewModel? node, CancellationToken ct) + { + if (node is null) return; + SelectedNode = node; + if (OnSelectNode is not null) + { + await OnSelectNode(node, ct); + } + } + + /// Converts a RepoTree into VM nodes (full recursive build up-front). + public void Load(RepoTree tree, string owner, string repo, string @ref) + { + _owner = owner; + _repo = repo; + _ref = @ref; + + RootNodes.Clear(); + foreach (RepoTreeNode child in tree.Root.Children) + { + RootNodes.Add(BuildNodeVm(child, parent: null)); + } + + IsTruncated = tree.Truncated; + RebuildFilter(); + } + + /// Truncated-tree fallback: load children of a directory node via the REST API. + public async Task LoadDirectoryAsync(RepoTreeNodeViewModel parent, CancellationToken ct) + { + if (parent.ChildrenLoaded) return; + + parent.IsLoadingChildren = true; + try + { + IReadOnlyList nodes = + await _treeService.LoadDirectoryAsync(_owner, _repo, parent.Path, _ref, ct); + + parent.Children.Clear(); + foreach (JitHub.Models.RepoContentNode n in nodes) + { + var model = new RepoTreeNode + { + Name = n.Name ?? string.Empty, + Path = n.Path ?? string.Empty, + Sha = n.Sha, + IsDirectory = n.IsDir, + }; + parent.Children.Add(new RepoTreeNodeViewModel(model, _languageResolver, parent)); + } + parent.ChildrenLoaded = true; + } + finally + { + parent.IsLoadingChildren = false; + } + } + + private RepoTreeNodeViewModel BuildNodeVm(RepoTreeNode model, RepoTreeNodeViewModel? parent) + { + var vm = new RepoTreeNodeViewModel(model, _languageResolver, parent); + foreach (RepoTreeNode child in model.Children) + { + vm.Children.Add(BuildNodeVm(child, vm)); + } + vm.ChildrenLoaded = model.Children.Count > 0 || !model.IsDirectory; + return vm; + } + + private void RebuildFilter() + { + string filter = FilterText?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(filter)) + { + _filteredRootNodes = RootNodes; + } + else + { + var flat = FlattenLeaves(RootNodes, filter); + _filteredRootNodes = new ObservableCollection(flat); + } + + OnPropertyChanged(nameof(FilteredRootNodes)); + } + + private static IEnumerable FlattenLeaves( + IEnumerable nodes, + string filter) + { + foreach (RepoTreeNodeViewModel node in nodes) + { + if (!node.IsDirectory) + { + if (node.Path.Contains(filter, StringComparison.OrdinalIgnoreCase)) + yield return node; + } + else + { + foreach (RepoTreeNodeViewModel child in FlattenLeaves(node.Children, filter)) + yield return child; + } + } + } +} diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoTreeNodeViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoTreeNodeViewModel.cs new file mode 100644 index 0000000..c27c0d2 --- /dev/null +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoTreeNodeViewModel.cs @@ -0,0 +1,43 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using JitHub.Models.CodeViewer; +using JitHub.Services.CodeViewer; + +namespace JitHub.WinUI.ViewModels.CodeViewer; + +public sealed partial class RepoTreeNodeViewModel : ObservableObject +{ + public string Name { get; } + public string Path { get; } + public string Sha { get; } + public bool IsDirectory { get; } + public long? Size { get; } + + [ObservableProperty] + public partial bool IsExpanded { get; set; } + + [ObservableProperty] + public partial bool IsLoadingChildren { get; set; } + + public bool ChildrenLoaded { get; set; } + + public string LanguageId { get; } + + public ObservableCollection Children { get; } = []; + + public RepoTreeNodeViewModel? Parent { get; } + + public RepoTreeNodeViewModel(RepoTreeNode model, ILanguageIdResolver languageResolver, RepoTreeNodeViewModel? parent = null) + { + Name = model.Name; + Path = model.Path; + Sha = model.Sha ?? string.Empty; + IsDirectory = model.IsDirectory; + Size = model.Size; + Parent = parent; + + LanguageId = IsDirectory + ? string.Empty + : languageResolver.Resolve(model.Name); + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml new file mode 100644 index 0000000..b3745b9 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml @@ -0,0 +1,23 @@ + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs new file mode 100644 index 0000000..7219d43 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs @@ -0,0 +1,467 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace JitHub.WinUI.Views.Controls.CodeViewer; + +/// +/// JitHub-themed wrapper around WinUIEditor.CodeEditorControl (Scintilla). +/// All Scintilla API calls are deferred until the inner editor is Loaded. +/// +public sealed partial class CodeEditorControl : UserControl +{ + // Scintilla STYLE_DEFAULT = 32, STYLE_LINENUMBER = 33 + private const int StyleDefault = 32; + private const int StyleLineNumber = 33; + + // Scintilla margin 0 is the line-number margin by default + private const int LineNumberMargin = 0; + + private bool _isInnerReady; + + // ────────────────────────────────────────────────────────────────── + // DependencyProperty: Text + // ────────────────────────────────────────────────────────────────── + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register( + nameof(Text), + typeof(string), + typeof(CodeEditorControl), + new PropertyMetadata(string.Empty, OnTextChanged)); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var ctrl = (CodeEditorControl)d; + if (!ctrl._isInnerReady) return; + ctrl.ApplyText(); + } + + // ────────────────────────────────────────────────────────────────── + // DependencyProperty: LanguageId + // ────────────────────────────────────────────────────────────────── + public static readonly DependencyProperty LanguageIdProperty = + DependencyProperty.Register( + nameof(LanguageId), + typeof(string), + typeof(CodeEditorControl), + new PropertyMetadata("plaintext", OnLanguageIdChanged)); + + public string LanguageId + { + get => (string)GetValue(LanguageIdProperty); + set => SetValue(LanguageIdProperty, value); + } + + private static void OnLanguageIdChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var ctrl = (CodeEditorControl)d; + if (!ctrl._isInnerReady) return; + ctrl.ApplyLanguageId(); + ctrl.ApplyFontSize(); // re-apply size since HighlightingLanguage resets styles + ctrl.ApplyThemeColors(); + } + + // ────────────────────────────────────────────────────────────────── + // DependencyProperty: IsReadOnlyEditor + // ────────────────────────────────────────────────────────────────── + public static readonly DependencyProperty IsReadOnlyEditorProperty = + DependencyProperty.Register( + nameof(IsReadOnlyEditor), + typeof(bool), + typeof(CodeEditorControl), + new PropertyMetadata(true, OnIsReadOnlyEditorChanged)); + + /// + /// Alias for read-only mode; named IsReadOnlyEditor to avoid clash with FrameworkElement.IsReadOnly. + /// + public bool IsReadOnlyEditor + { + get => (bool)GetValue(IsReadOnlyEditorProperty); + set => SetValue(IsReadOnlyEditorProperty, value); + } + + private static void OnIsReadOnlyEditorChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var ctrl = (CodeEditorControl)d; + if (!ctrl._isInnerReady) return; + ctrl.ApplyReadOnly(); + } + + // ────────────────────────────────────────────────────────────────── + // DependencyProperty: FontSize (shadows FrameworkElement.FontSize intentionally) + // ────────────────────────────────────────────────────────────────── + public static readonly new DependencyProperty FontSizeProperty = + DependencyProperty.Register( + nameof(FontSize), + typeof(double), + typeof(CodeEditorControl), + new PropertyMetadata(13.0, OnFontSizeChanged)); + + public new double FontSize + { + get => (double)GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + private static void OnFontSizeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var ctrl = (CodeEditorControl)d; + if (!ctrl._isInnerReady) return; + ctrl.ApplyFontSize(); + } + + // ────────────────────────────────────────────────────────────────── + // DependencyProperty: ShowLineNumbers + // ────────────────────────────────────────────────────────────────── + public static readonly DependencyProperty ShowLineNumbersProperty = + DependencyProperty.Register( + nameof(ShowLineNumbers), + typeof(bool), + typeof(CodeEditorControl), + new PropertyMetadata(true, OnShowLineNumbersChanged)); + + public bool ShowLineNumbers + { + get => (bool)GetValue(ShowLineNumbersProperty); + set => SetValue(ShowLineNumbersProperty, value); + } + + private static void OnShowLineNumbersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var ctrl = (CodeEditorControl)d; + if (!ctrl._isInnerReady) return; + ctrl.ApplyShowLineNumbers(); + } + + // ────────────────────────────────────────────────────────────────── + // DependencyProperty: WordWrap + // ────────────────────────────────────────────────────────────────── + public static readonly DependencyProperty WordWrapProperty = + DependencyProperty.Register( + nameof(WordWrap), + typeof(bool), + typeof(CodeEditorControl), + new PropertyMetadata(false, OnWordWrapChanged)); + + public bool WordWrap + { + get => (bool)GetValue(WordWrapProperty); + set => SetValue(WordWrapProperty, value); + } + + private static void OnWordWrapChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var ctrl = (CodeEditorControl)d; + if (!ctrl._isInnerReady) return; + ctrl.ApplyWordWrap(); + } + + // ────────────────────────────────────────────────────────────────── + // DependencyProperty: FilePath (informational) + // ────────────────────────────────────────────────────────────────── + public static readonly DependencyProperty FilePathProperty = + DependencyProperty.Register( + nameof(FilePath), + typeof(string), + typeof(CodeEditorControl), + new PropertyMetadata(string.Empty)); + + public string FilePath + { + get => (string)GetValue(FilePathProperty); + set => SetValue(FilePathProperty, value); + } + + // ────────────────────────────────────────────────────────────────── + // Construction and Loaded + // ────────────────────────────────────────────────────────────────── + + public CodeEditorControl() + { + InitializeComponent(); + InnerEditor.Loaded += OnInnerEditorLoaded; + } + + private void OnInnerEditorLoaded(object sender, RoutedEventArgs e) + { + _isInnerReady = true; + ApplyLanguageId(); // sets up lexer + token colors (WinUIEdit may call StyleClearAll) + ApplyFontSize(); // re-apply font sizes for all styles (after language reset) + ApplyThemeColors(); // override bg/fg/linenumber with app theme + ApplyText(); + ApplyReadOnly(); + ApplyShowLineNumbers(); + ApplyWordWrap(); + ActualThemeChanged += OnActualThemeChanged; + } + + private void OnActualThemeChanged(FrameworkElement sender, object args) + { + ApplyLanguageId(); + ApplyFontSize(); + ApplyThemeColors(); + } + + // ────────────────────────────────────────────────────────────────── + // Apply helpers + // ────────────────────────────────────────────────────────────────── + + private void ApplyText() + { + try + { + InnerEditor.Editor.SetText(Text ?? string.Empty); + } + catch (Exception ex) + { + Debug.WriteLine($"[CodeEditorControl] ApplyText failed: {ex.Message}"); + } + } + + private void ApplyLanguageId() + { + string langId = LanguageId ?? "plaintext"; + try + { + string? winuiId = ScintillaLexerDatabase.GetWinUIEditId(langId); + ScintillaLexerDatabase.LexillaConfig? lexilla = ScintillaLexerDatabase.GetLexillaConfig(langId); + bool isDark = ActualTheme == ElementTheme.Dark; + + if (winuiId != null) + { + // WinUIEdit native: sets up lexer + VS Code token colors internally. + InnerEditor.HighlightingLanguage = winuiId; + + // Apply custom keywords for languages mapped to "cpp" (Java, Go, Rust, etc.) + var kw = ScintillaLexerDatabase.GetKeywordOverride(langId); + if (kw != null) + { + if (kw.Value.Keywords0 != null) SendKeywords(0, kw.Value.Keywords0); + if (kw.Value.Keywords1 != null) SendKeywords(1, kw.Value.Keywords1); + } + + // Apply extra token colors for cpp-mapped Lexilla configs where relevant + if (lexilla?.StyleMap != null) + ApplyLexillaTokenColors(lexilla, isDark); + } + else if (lexilla != null) + { + // Lexilla-direct: reset to plaintext first (clears any prior lexer), + // then switch to the Lexilla lexer and apply our own token colors. + InnerEditor.HighlightingLanguage = "plaintext"; + SendLexerLanguage(lexilla.LexerName); + if (lexilla.Keywords0 != null) SendKeywords(0, lexilla.Keywords0); + if (lexilla.Keywords1 != null) SendKeywords(1, lexilla.Keywords1); + if (lexilla.StyleMap != null) ApplyLexillaTokenColors(lexilla, isDark); + } + else + { + InnerEditor.HighlightingLanguage = "plaintext"; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[CodeEditorControl] ApplyLanguageId({langId}) failed: {ex.Message}"); + } + } + + private void ApplyReadOnly() + { + try + { + InnerEditor.Editor.ReadOnly = IsReadOnlyEditor; + } + catch (Exception ex) + { + Debug.WriteLine($"[CodeEditorControl] ApplyReadOnly failed: {ex.Message}"); + } + } + + /// + /// Sets the font size on every Scintilla style (0–127) without calling StyleClearAll, + /// so syntax-highlighting token colors are preserved. + /// + private void ApplyFontSize() + { + try + { + int fractional = (int)(FontSize * 100); + var editor = InnerEditor.Editor; + for (int i = 0; i <= 127; i++) + editor.StyleSetSizeFractional(i, fractional); + } + catch (Exception ex) + { + Debug.WriteLine($"[CodeEditorControl] ApplyFontSize failed: {ex.Message}"); + } + } + + private void ApplyShowLineNumbers() + { + try + { + // Margin 0 is the default line-number margin in Scintilla + // Width 0 hides it; ~40px shows 4-digit line numbers + int width = ShowLineNumbers ? 40 : 0; + InnerEditor.Editor.SetMarginWidthN(LineNumberMargin, width); + } + catch (Exception ex) + { + Debug.WriteLine($"[CodeEditorControl] ApplyShowLineNumbers failed: {ex.Message}"); + } + } + + private void ApplyWordWrap() + { + try + { + // SC_WRAP_NONE = 0, SC_WRAP_WORD = 1 + InnerEditor.Editor.WrapMode = WordWrap + ? WinUIEditor.Wrap.Word + : WinUIEditor.Wrap.None; + } + catch (Exception ex) + { + Debug.WriteLine($"[CodeEditorControl] ApplyWordWrap failed: {ex.Message}"); + } + } + + // ────────────────────────────────────────────────────────────────── + // Theme color application (never resets lexer/token colors) + // ────────────────────────────────────────────────────────────────── + + private void ApplyThemeColors() + { + try + { + var resources = Application.Current.Resources; + + var bgColor = GetBrushColor(resources, "AppCanvasBrush"); + var fgColor = TryGetBrushColor(resources, "AppOnSurfaceBrush") + ?? GetBrushColor(resources, "AppInkBrush"); + var mutedColor = TryGetBrushColor(resources, "AppInkMutedBrush") ?? fgColor; + var accentColor = GetResourceColor(resources, "AppAccentColor"); + var selColor = BlendColors(bgColor, accentColor, 0.35f); + + var editor = InnerEditor.Editor; + + // Override STYLE_DEFAULT bg/fg. We intentionally skip StyleClearAll() to + // preserve the lexer token colors set by ApplyLanguageId(). + editor.StyleSetBack(StyleDefault, ToBgr(bgColor)); + editor.StyleSetFore(StyleDefault, ToBgr(fgColor)); + + // Override STYLE_LINENUMBER (33) so gutter never shows white in dark theme. + editor.StyleSetBack(StyleLineNumber, ToBgr(bgColor)); + editor.StyleSetFore(StyleLineNumber, ToBgr(mutedColor)); + + editor.SetSelBack(true, ToBgr(selColor)); + editor.CaretFore = ToBgr(fgColor); + try { editor.SetWhitespaceBack(true, ToBgr(bgColor)); } catch { } + } + catch (Exception ex) + { + Debug.WriteLine($"[CodeEditorControl] ApplyThemeColors failed: {ex.Message}"); + } + } + + // ────────────────────────────────────────────────────────────────── + // Lexilla via SendMessage + // ────────────────────────────────────────────────────────────────── + + private void SendLexerLanguage(string lexerName) + { + IntPtr ptr = Marshal.StringToHGlobalAnsi(lexerName); + try + { + InnerEditor.SendMessage( + (WinUIEditor.ScintillaMessage)ScintillaLexerDatabase.SciSetLexerLanguage, + 0, + ptr.ToInt64()); + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + + private void SendKeywords(int set, string keywords) + { + IntPtr ptr = Marshal.StringToHGlobalAnsi(keywords); + try + { + InnerEditor.SendMessage( + (WinUIEditor.ScintillaMessage)ScintillaLexerDatabase.SciSetKeyWords, + (ulong)set, + ptr.ToInt64()); + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + + private void ApplyLexillaTokenColors(ScintillaLexerDatabase.LexillaConfig config, bool isDark) + { + if (config.StyleMap is null) return; + var editor = InnerEditor.Editor; + foreach (var (styleId, kind) in config.StyleMap) + { + var color = ScintillaLexerDatabase.TokenColor(kind, isDark); + editor.StyleSetFore(styleId, ToBgr(color)); + } + } + + // ────────────────────────────────────────────────────────────────── + // Color helpers + // ────────────────────────────────────────────────────────────────── + + /// Convert Windows.UI.Color to Scintilla BGR integer (COLORREF format). + private static int ToBgr(Windows.UI.Color c) + => c.R | (c.G << 8) | (c.B << 16); + + private static Windows.UI.Color GetBrushColor(ResourceDictionary resources, string key) + { + if (resources[key] is SolidColorBrush brush) + return brush.Color; + return Windows.UI.Color.FromArgb(255, 0, 0, 0); + } + + private static Windows.UI.Color? TryGetBrushColor(ResourceDictionary resources, string key) + { + if (resources.ContainsKey(key) && resources[key] is SolidColorBrush brush) + return brush.Color; + return null; + } + + private static Windows.UI.Color GetResourceColor(ResourceDictionary resources, string key) + { + if (resources.ContainsKey(key) && resources[key] is Windows.UI.Color color) + return color; + return Windows.UI.Color.FromArgb(255, 0x3E, 0x7B, 0x64); + } + + /// + /// Blend over using straight alpha. + /// is in [0, 1]. + /// + private static Windows.UI.Color BlendColors( + Windows.UI.Color @base, + Windows.UI.Color overlay, + float overlayAlpha) + { + float a = overlayAlpha; + byte r = (byte)(@base.R + (overlay.R - @base.R) * a); + byte g = (byte)(@base.G + (overlay.G - @base.G) * a); + byte b = (byte)(@base.B + (overlay.B - @base.B) * a); + return Windows.UI.Color.FromArgb(255, r, g, b); + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml new file mode 100644 index 0000000..06473b6 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml.cs new file mode 100644 index 0000000..fe9cef3 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/FilePreviewHost.xaml.cs @@ -0,0 +1,113 @@ +using System; +using System.ComponentModel; +using JitHub.Models.CodeViewer; +using JitHub.WinUI.ViewModels.CodeViewer; +using JitHub.WinUI.Views.Controls.CodeViewer.Renderers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace JitHub.WinUI.Views.Controls.CodeViewer; + +public sealed partial class FilePreviewHost : UserControl +{ + private RepoFilePreviewViewModel? _viewModel; + + public FilePreviewHost() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + if (_viewModel is not null) + { + _viewModel.PropertyChanged -= OnViewModelChanged; + } + + _viewModel = DataContext as RepoFilePreviewViewModel; + + if (_viewModel is not null) + { + _viewModel.PropertyChanged += OnViewModelChanged; + } + + UpdateState(); + } + + private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(RepoFilePreviewViewModel.IsLoading) + or nameof(RepoFilePreviewViewModel.ErrorMessage) + or nameof(RepoFilePreviewViewModel.Kind) + or nameof(RepoFilePreviewViewModel.CurrentFile)) + { + DispatcherQueue.TryEnqueue(UpdateState); + } + } + + private void UpdateState() + { + var vm = _viewModel; + if (vm is null) + { + EmptyState.Visibility = Visibility.Visible; + LoadingState.Visibility = Visibility.Collapsed; + ErrorState.Visibility = Visibility.Collapsed; + RendererHost.Content = null; + return; + } + + if (vm.IsLoading) + { + EmptyState.Visibility = Visibility.Collapsed; + ErrorState.Visibility = Visibility.Collapsed; + LoadingState.Visibility = Visibility.Visible; + return; + } + + LoadingState.Visibility = Visibility.Collapsed; + + if (!string.IsNullOrEmpty(vm.ErrorMessage)) + { + EmptyState.Visibility = Visibility.Collapsed; + ErrorMessageText.Text = vm.ErrorMessage; + ErrorState.Visibility = Visibility.Visible; + RendererHost.Content = null; + return; + } + + ErrorState.Visibility = Visibility.Collapsed; + + if (vm.CurrentFile is null) + { + EmptyState.Visibility = Visibility.Visible; + RendererHost.Content = null; + return; + } + + EmptyState.Visibility = Visibility.Collapsed; + RendererHost.Content = CreateRenderer(vm); + } + + private static FrameworkElement CreateRenderer(RepoFilePreviewViewModel vm) + { + FrameworkElement renderer = vm.Kind switch + { + RepoFilePreviewKind.Code => new CodePreview(), + RepoFilePreviewKind.Markdown => new MarkdownPreview(), + RepoFilePreviewKind.Csv => new CsvPreview(), + RepoFilePreviewKind.Json => new JsonPreview(), + RepoFilePreviewKind.Xml => new XmlPreview(), + RepoFilePreviewKind.Yaml => new YamlPreview(), + RepoFilePreviewKind.Image => new ImagePreview(), + RepoFilePreviewKind.Svg => new SvgPreview(), + RepoFilePreviewKind.Hex => new HexPreview(), + RepoFilePreviewKind.Unsupported => new UnsupportedPreview(), + RepoFilePreviewKind.TooLarge => new UnsupportedPreview(), + _ => new UnsupportedPreview(), + }; + renderer.DataContext = vm; + return renderer; + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml new file mode 100644 index 0000000..ca820bb --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml @@ -0,0 +1,19 @@ + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml.cs new file mode 100644 index 0000000..0872bcd --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CodePreview.xaml.cs @@ -0,0 +1,25 @@ +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; + +/// +/// Displays code files using the Scintilla-backed CodeEditorControl. +/// DataContext must be a . +/// +public sealed partial class CodePreview : UserControl +{ + public CodePreview() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + Bindings.Update(); + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml new file mode 100644 index 0000000..8fe645a --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml.cs new file mode 100644 index 0000000..422f188 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/CsvPreview.xaml.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.UI.Controls; +using CsvHelper; +using CsvHelper.Configuration; +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; + +namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; + +/// +/// Renders CSV / TSV data in a DataGrid (rich) or raw code view (plain). +/// DataContext must be a . +/// +public sealed partial class CsvPreview : UserControl +{ + private readonly DispatcherQueue _dispatcher; + private string? _lastText; + + public CsvPreview() + { + InitializeComponent(); + _dispatcher = DispatcherQueue.GetForCurrentThread(); + DataContextChanged += OnDataContextChanged; + } + + private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SyncSegmented(); + UpdateContent(); + } + + private void SyncSegmented() + { + ViewModeSegmented.SelectedIndex = (ViewModel?.ShowRichPreview ?? true) ? 0 : 1; + } + + private void ViewModeSegmented_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + var vm = ViewModel; + if (vm is null) return; + bool wantsRich = ViewModeSegmented.SelectedIndex == 0; + if (vm.ShowRichPreview != wantsRich) + vm.ShowRichPreview = wantsRich; + UpdateContent(); + } + + private void UpdateContent() + { + var vm = ViewModel; + var text = vm?.Text ?? string.Empty; + var rich = vm?.ShowRichPreview ?? true; + _lastText = text; + + DataGrid.Visibility = rich ? Visibility.Visible : Visibility.Collapsed; + PlainEditor.Visibility = rich ? Visibility.Collapsed : Visibility.Visible; + + if (!rich) + { + PlainEditor.Text = text; + return; + } + + // Detect delimiter from file extension + char delimiter = ','; + if (vm?.CurrentFile?.Path is { } path && + path.EndsWith(".tsv", StringComparison.OrdinalIgnoreCase)) + { + delimiter = '\t'; + } + + Task.Run(() => ParseCsv(text, delimiter)) + .ContinueWith(t => + { + _dispatcher.TryEnqueue(() => + { + if (_lastText != text) return; + if (t.Exception is not null) return; + + var (headers, rows) = t.Result; + PopulateDataGrid(headers, rows); + }); + }, TaskScheduler.Default); + } + + private static (string[] Headers, List Rows) ParseCsv(string text, char delimiter) + { + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = delimiter.ToString(), + HasHeaderRecord = true, + MissingFieldFound = null, + BadDataFound = null, + }; + + using var reader = new StringReader(text); + using var csv = new CsvReader(reader, config); + + csv.Read(); + csv.ReadHeader(); + var headers = csv.HeaderRecord ?? []; + + var rows = new List(); + while (csv.Read()) + { + var row = new string[headers.Length]; + for (int i = 0; i < headers.Length; i++) + row[i] = csv.GetField(i) ?? string.Empty; + rows.Add(row); + } + + return (headers, rows); + } + + private void PopulateDataGrid(string[] headers, List rows) + { + DataGrid.Columns.Clear(); + + for (int i = 0; i < headers.Length; i++) + { + DataGrid.Columns.Add(new DataGridTextColumn + { + Header = headers[i], + Binding = new Binding { Path = new PropertyPath($"[{i}]") }, + IsReadOnly = true, + }); + } + + DataGrid.ItemsSource = rows; + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml new file mode 100644 index 0000000..c11d9fc --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml.cs new file mode 100644 index 0000000..0adbc37 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/HexPreview.xaml.cs @@ -0,0 +1,102 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; + +/// +/// Renders binary files as a 16-byte-per-row hex dump with ASCII gutter. +/// DataContext must be a . +/// +public sealed partial class HexPreview : UserControl +{ + private const int BytesPerRow = 16; + + private readonly DispatcherQueue _dispatcher; + private byte[]? _lastBytes; + + public HexPreview() + { + InitializeComponent(); + _dispatcher = DispatcherQueue.GetForCurrentThread(); + DataContextChanged += OnDataContextChanged; + } + + private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + var vm = ViewModel; + var bytes = vm?.Bytes; + _lastBytes = bytes; + + HeaderText.Text = bytes is { Length: > 0 } + ? $"{bytes.Length:N0} bytes" + : "0 bytes"; + + if (bytes is not { Length: > 0 }) + { + HexEditor.Text = string.Empty; + return; + } + + Task.Run(() => BuildHexDump(bytes)) + .ContinueWith(t => + { + _dispatcher.TryEnqueue(() => + { + if (_lastBytes == bytes) + HexEditor.Text = t.Result; + }); + }, TaskScheduler.Default); + } + + private static string BuildHexDump(byte[] bytes) + { + // Pre-allocate: each row has offset(8) + 2 spaces + 3*16 hex + 1 space + 16 ascii + newline + // ~75 chars per row, one row per 16 bytes + int rows = (bytes.Length + BytesPerRow - 1) / BytesPerRow; + var sb = new StringBuilder(rows * 76); + + // Header line + sb.Append("Offset "); + for (int i = 0; i < BytesPerRow; i++) + sb.Append($"{i:X2} "); + sb.Append(" ASCII\n"); + sb.Append(new string('-', 76)); + sb.Append('\n'); + + for (int row = 0; row < rows; row++) + { + int offset = row * BytesPerRow; + sb.Append($"{offset:X8} "); + + int count = Math.Min(BytesPerRow, bytes.Length - offset); + + // Hex section + for (int i = 0; i < count; i++) + sb.Append($"{bytes[offset + i]:X2} "); + + // Pad if last row is partial + for (int i = count; i < BytesPerRow; i++) + sb.Append(" "); + + sb.Append(' '); + + // ASCII section + for (int i = 0; i < count; i++) + { + byte b = bytes[offset + i]; + sb.Append(b >= 32 && b < 127 ? (char)b : '.'); + } + + sb.Append('\n'); + } + + return sb.ToString(); + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml new file mode 100644 index 0000000..98592e1 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml.cs new file mode 100644 index 0000000..ca1bee7 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/ImagePreview.xaml.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Threading.Tasks; +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Storage.Streams; + +namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; + +/// +/// Renders image files (PNG, JPG, GIF, BMP, ICO, TIFF, HEIF, WebP) in a +/// zoomable ScrollViewer. DataContext must be a . +/// +public sealed partial class ImagePreview : UserControl +{ + private readonly DispatcherQueue _dispatcher; + private byte[]? _lastBytes; + + public ImagePreview() + { + InitializeComponent(); + _dispatcher = DispatcherQueue.GetForCurrentThread(); + DataContextChanged += OnDataContextChanged; + } + + private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + LoadImage(); + } + + private void LoadImage() + { + var vm = ViewModel; + var bytes = vm?.Bytes; + _lastBytes = bytes; + + if (bytes is not { Length: > 0 }) + { + ShowError(); + return; + } + + UpdateFooter(vm!, null, null); + + _ = LoadImageAsync(bytes, vm!); + } + + private async Task LoadImageAsync(byte[] bytes, RepoFilePreviewViewModel vm) + { + try + { + var stream = new InMemoryRandomAccessStream(); + await stream.WriteAsync(bytes.AsBuffer()); + stream.Seek(0); + + var bitmap = new BitmapImage(); + bitmap.ImageOpened += (s, e) => + { + _dispatcher.TryEnqueue(() => + { + if (_lastBytes != bytes) return; + UpdateFooter(vm, bitmap.PixelWidth, bitmap.PixelHeight); + }); + }; + bitmap.ImageFailed += (s, e) => + { + _dispatcher.TryEnqueue(() => + { + if (_lastBytes != bytes) return; + ShowError(); + }); + }; + + await bitmap.SetSourceAsync(stream); + + _dispatcher.TryEnqueue(() => + { + if (_lastBytes != bytes) return; + PreviewImage.Source = bitmap; + PreviewImage.Visibility = Visibility.Visible; + ErrorText.Visibility = Visibility.Collapsed; + }); + } + catch + { + _dispatcher.TryEnqueue(() => + { + if (_lastBytes == bytes) ShowError(); + }); + } + } + + private void ShowError() + { + PreviewImage.Source = null; + PreviewImage.Visibility = Visibility.Collapsed; + ErrorText.Visibility = Visibility.Visible; + } + + private void UpdateFooter(RepoFilePreviewViewModel vm, int? width, int? height) + { + var mime = vm.ImageMimeType ?? "image"; + var size = FormatBytes(vm.ByteSize); + var dims = (width.HasValue && height.HasValue) ? $" · {width}×{height}" : string.Empty; + FooterText.Text = $"{mime} · {size}{dims}"; + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / (1024.0 * 1024):F1} MB"; + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml new file mode 100644 index 0000000..78182bc --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml.cs new file mode 100644 index 0000000..3e21b55 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/JsonPreview.xaml.cs @@ -0,0 +1,88 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; + +/// +/// Renders JSON with optional pretty-printing via a rich/plain toggle. +/// DataContext must be a . +/// +public sealed partial class JsonPreview : UserControl +{ + private readonly DispatcherQueue _dispatcher; + private string? _lastText; + + public JsonPreview() + { + InitializeComponent(); + _dispatcher = DispatcherQueue.GetForCurrentThread(); + DataContextChanged += OnDataContextChanged; + } + + private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SyncSegmented(); + UpdateContent(); + } + + private void SyncSegmented() + { + ViewModeSegmented.SelectedIndex = (ViewModel?.ShowRichPreview ?? true) ? 0 : 1; + } + + private void ViewModeSegmented_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + var vm = ViewModel; + if (vm is null) return; + bool wantsRich = ViewModeSegmented.SelectedIndex == 0; + if (vm.ShowRichPreview != wantsRich) + vm.ShowRichPreview = wantsRich; + UpdateContent(); + } + + private void UpdateContent() + { + var vm = ViewModel; + var text = vm?.Text ?? string.Empty; + var rich = vm?.ShowRichPreview ?? true; + _lastText = text; + + if (!rich) + { + Editor.Text = text; + return; + } + + // Pretty-print on background thread + Task.Run(() => + { + string pretty; + try + { + using var doc = JsonDocument.Parse(text); + pretty = JsonSerializer.Serialize(doc.RootElement, + new JsonSerializerOptions { WriteIndented = true }); + } + catch + { + pretty = text; // fall back to raw on parse failure + } + return pretty; + }).ContinueWith(t => + { + _dispatcher.TryEnqueue(() => + { + // Only apply if the text hasn't changed while we were parsing + if (_lastText == text) + Editor.Text = t.Result; + }); + }, TaskScheduler.Default); + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml new file mode 100644 index 0000000..92e0564 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs new file mode 100644 index 0000000..28b5c67 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs @@ -0,0 +1,49 @@ +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; + +/// +/// Renders Markdown files with a rich/plain toggle. +/// DataContext must be a . +/// +public sealed partial class MarkdownPreview : UserControl +{ + public MarkdownPreview() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SyncSegmented(); + SyncPanels(); + Bindings.Update(); + } + + private void SyncSegmented() + { + ViewModeSegmented.SelectedIndex = (ViewModel?.ShowRichPreview ?? true) ? 0 : 1; + } + + private void SyncPanels() + { + bool rich = ViewModel?.ShowRichPreview ?? true; + RichPanel.Visibility = rich ? Visibility.Visible : Visibility.Collapsed; + PlainPanel.Visibility = rich ? Visibility.Collapsed : Visibility.Visible; + } + + private void ViewModeSegmented_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + var vm = ViewModel; + if (vm is null) return; + bool wantsRich = ViewModeSegmented.SelectedIndex == 0; + if (vm.ShowRichPreview != wantsRich) + vm.ShowRichPreview = wantsRich; + SyncPanels(); + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml new file mode 100644 index 0000000..cfc640e --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml.cs new file mode 100644 index 0000000..bbe5bc9 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/SvgPreview.xaml.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using SkiaSharp; +using SkiaSharp.Views.Windows; +using SvgSkia = Svg.Skia; + +namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; + +/// +/// Renders SVG files via Svg.Skia + SKXamlCanvas inside a zoomable ScrollViewer. +/// DataContext must be a . +/// +public sealed partial class SvgPreview : UserControl +{ + private readonly DispatcherQueue _dispatcher; + private SvgSkia.SKSvg? _svg; + private byte[]? _lastBytes; + + public SvgPreview() + { + InitializeComponent(); + _dispatcher = DispatcherQueue.GetForCurrentThread(); + DataContextChanged += OnDataContextChanged; + } + + private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + LoadSvg(); + } + + private void LoadSvg() + { + var vm = ViewModel; + var bytes = vm?.Bytes; + _lastBytes = bytes; + _svg = null; + + if (bytes is not { Length: > 0 }) + { + ShowError(); + return; + } + + Task.Run(() => + { + try + { + var svg = new SvgSkia.SKSvg(); + svg.Load(new MemoryStream(bytes)); + return svg.Picture is not null ? svg : null; + } + catch + { + return null; + } + }).ContinueWith(t => + { + _dispatcher.TryEnqueue(() => + { + if (_lastBytes != bytes) return; + + if (t.Result is null) + { + ShowError(); + return; + } + + _svg = t.Result; + ErrorText.Visibility = Visibility.Collapsed; + SvgCanvas.Visibility = Visibility.Visible; + SvgCanvas.Invalidate(); + }); + }, TaskScheduler.Default); + } + + private void SvgCanvas_PaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + canvas.Clear(SKColors.Transparent); + + var picture = _svg?.Picture; + if (picture is null) return; + + var cullRect = picture.CullRect; + if (cullRect.Width <= 0 || cullRect.Height <= 0) return; + + float scaleX = e.Info.Width / cullRect.Width; + float scaleY = e.Info.Height / cullRect.Height; + float scale = Math.Min(scaleX, scaleY); + + canvas.Save(); + canvas.Scale(scale, scale); + canvas.DrawPicture(picture); + canvas.Restore(); + } + + private void ShowError() + { + _svg = null; + ErrorText.Visibility = Visibility.Visible; + SvgCanvas.Visibility = Visibility.Collapsed; + } +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/UnsupportedPreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/UnsupportedPreview.xaml new file mode 100644 index 0000000..fe516c2 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/UnsupportedPreview.xaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml.cs new file mode 100644 index 0000000..7d50c53 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml.cs @@ -0,0 +1,89 @@ +using System.Windows.Input; +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace JitHub.WinUI.Views.Controls.CodeViewer; + +/// +/// Breadcrumb + action bar for the native code viewer. +/// DataContext must be set to a by the owner. +/// Wire , , +/// , and from +/// RepoCodePageViewModel in the page. +/// +public sealed partial class RepoCodeBreadcrumb : UserControl +{ + // ── Dependency Properties ───────────────────────────────────────────── + + public static readonly DependencyProperty GoBackCommandProperty = + DependencyProperty.Register( + nameof(GoBackCommand), typeof(ICommand), + typeof(RepoCodeBreadcrumb), new PropertyMetadata(null)); + + public static readonly DependencyProperty GoForwardCommandProperty = + DependencyProperty.Register( + nameof(GoForwardCommand), typeof(ICommand), + typeof(RepoCodeBreadcrumb), new PropertyMetadata(null)); + + public static readonly DependencyProperty CanGoBackProperty = + DependencyProperty.Register( + nameof(CanGoBack), typeof(bool), + typeof(RepoCodeBreadcrumb), new PropertyMetadata(false)); + + public static readonly DependencyProperty CanGoForwardProperty = + DependencyProperty.Register( + nameof(CanGoForward), typeof(bool), + typeof(RepoCodeBreadcrumb), new PropertyMetadata(false)); + + public ICommand? GoBackCommand + { + get => (ICommand?)GetValue(GoBackCommandProperty); + set => SetValue(GoBackCommandProperty, value); + } + + public ICommand? GoForwardCommand + { + get => (ICommand?)GetValue(GoForwardCommandProperty); + set => SetValue(GoForwardCommandProperty, value); + } + + public bool CanGoBack + { + get => (bool)GetValue(CanGoBackProperty); + set => SetValue(CanGoBackProperty, value); + } + + public bool CanGoForward + { + get => (bool)GetValue(CanGoForwardProperty); + set => SetValue(CanGoForwardProperty, value); + } + + // ── Constructor ─────────────────────────────────────────────────────── + + public RepoCodeBreadcrumb() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + // Typed accessor for x:Bind expressions on ViewModel members. + // Private is fine — x:Bind generates code in the same partial class. + private RepoCodeBreadcrumbViewModel? ViewModel => DataContext as RepoCodeBreadcrumbViewModel; + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + // Re-evaluate all x:Bind expressions whenever the DataContext is replaced. + Bindings.Update(); + } + + // ── Static helper for DataTemplate x:Bind expressions ──────────────── + + /// + /// Returns when the segment is NOT the root, + /// so the "›" separator is shown between path segments. + /// + public static Visibility NotRootVis(bool isRoot) + => isRoot ? Visibility.Collapsed : Visibility.Visible; +} diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml new file mode 100644 index 0000000..b4890ea --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml.cs new file mode 100644 index 0000000..be65189 --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml.cs @@ -0,0 +1,151 @@ +using System; +using System.ComponentModel; +using System.Threading; +using JitHub.WinUI.ViewModels.CodeViewer; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace JitHub.WinUI.Views.Controls.CodeViewer; + +/// +/// File-tree panel for the native code viewer. +/// DataContext must be set to a by the owner. +/// +/// Uses TreeView in TreeViewNode mode (RootNodes collection, not ItemsSource). +/// This avoids the WinUI 3 ItemsSource-binding bug where {Binding Children} on +/// TreeViewItem.ItemsSource is unreliable and never reveals child items. +/// +public sealed partial class RepoFileTreeView : UserControl +{ + private RepoFileTreeViewModel? _subscribedViewModel; + + public RepoFileTreeView() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + // Typed accessor for x:Bind expressions in the XAML. + private RepoFileTreeViewModel? ViewModel => DataContext as RepoFileTreeViewModel; + + // ── DataContext management ──────────────────────────────────────── + + private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + // Unsubscribe from the old VM. + if (_subscribedViewModel != null) + { + _subscribedViewModel.PropertyChanged -= OnViewModelPropertyChanged; + _subscribedViewModel = null; + } + + // Subscribe to the new VM and refresh x:Bind expressions. + if (ViewModel is { } vm) + { + _subscribedViewModel = vm; + vm.PropertyChanged += OnViewModelPropertyChanged; + + // If the tree is already loaded, populate immediately. + if (!vm.IsLoading) + RebuildTreeView(vm); + } + + Bindings.Update(); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(RepoFileTreeViewModel.IsLoading) && + sender is RepoFileTreeViewModel vm && !vm.IsLoading) + { + RebuildTreeView(vm); + } + } + + // ── TreeViewNode construction ───────────────────────────────────── + + /// + /// Clears and rebuilds the TreeView root nodes from the VM's RootNodes. + /// Runs on the UI thread. Nodes are created lazily (children populated on expand). + /// + private void RebuildTreeView(RepoFileTreeViewModel vm) + { + FileTreeView.RootNodes.Clear(); + foreach (RepoTreeNodeViewModel rootVm in vm.RootNodes) + FileTreeView.RootNodes.Add(CreateTreeViewNode(rootVm)); + } + + private static TreeViewNode CreateTreeViewNode(RepoTreeNodeViewModel nodeVm) + { + return new TreeViewNode + { + Content = nodeVm, + // Show the expand chevron for directories even before children are loaded. + HasUnrealizedChildren = nodeVm.IsDirectory, + }; + } + + // ── TreeView event handlers ─────────────────────────────────────── + + private void OnItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args) + { + if (args.InvokedItem is not TreeViewNode treeNode) return; + if (treeNode.Content is not RepoTreeNodeViewModel nodeVm) return; + + if (nodeVm.IsDirectory) + { + // Toggle expand / collapse on the row click. + treeNode.IsExpanded = !treeNode.IsExpanded; + } + else + { + ViewModel?.SelectNodeCommand.Execute(nodeVm); + } + } + + private async void OnExpanding(TreeView sender, TreeViewExpandingEventArgs args) + { + if (args.Node.Content is not RepoTreeNodeViewModel nodeVm) return; + + nodeVm.IsExpanded = true; + + // For the truncated-tree fallback, lazy-load directory children first. + if (!nodeVm.ChildrenLoaded && nodeVm.IsDirectory && ViewModel != null) + await ViewModel.LoadDirectoryAsync(nodeVm, CancellationToken.None); + + // Populate TreeViewNode children from the (now loaded) VM children. + // Guard with Count == 0 so re-expanding doesn't duplicate nodes. + if (args.Node.Children.Count == 0) + { + foreach (RepoTreeNodeViewModel childVm in nodeVm.Children) + args.Node.Children.Add(CreateTreeViewNode(childVm)); + + args.Node.HasUnrealizedChildren = false; + } + } + + private void OnCollapsed(TreeView sender, TreeViewCollapsedEventArgs args) + { + if (args.Node.Content is RepoTreeNodeViewModel nodeVm) + nodeVm.IsExpanded = false; + } + + // ── Static helpers for x:Bind function calls inside DataTemplate ── + // Must be public so the x:Bind–generated code can call them via the + // "local:RepoFileTreeView.Method()" syntax. + + public static Visibility FolderOpenVis(bool isDirectory, bool isExpanded) + => (isDirectory && isExpanded) ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility FolderClosedVis(bool isDirectory, bool isExpanded) + => (isDirectory && !isExpanded) ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility FileVis(bool isDirectory) + => isDirectory ? Visibility.Collapsed : Visibility.Visible; + + // ── Instance helper for top-level x:Bind expressions ───────────── + + public Visibility BoolToVis(bool value) + => value ? Visibility.Visible : Visibility.Collapsed; +} + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs b/JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs new file mode 100644 index 0000000..cb738bc --- /dev/null +++ b/JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs @@ -0,0 +1,804 @@ +using System; +using System.Collections.Generic; +using Windows.UI; + +namespace JitHub.WinUI.Views.Controls.CodeViewer; + +/// +/// Defines how each language ID maps to either a WinUIEdit HighlightingLanguage or a +/// Lexilla lexer set directly via SCI_SETLEXERLANGUAGE. +/// +internal static class ScintillaLexerDatabase +{ + // ── Scintilla message constants ──────────────────────────────────────────── + public const int SciSetLexerLanguage = 4006; // SCI_SETLEXERLANGUAGE lParam=const char* language + public const int SciSetKeyWords = 4005; // SCI_SETKEYWORDS wParam=set#, lParam=const char* + + // ── Token kinds for colour mapping ──────────────────────────────────────── + public enum TokenKind + { + Default, + Comment, + String, + Keyword, + Keyword2, + Number, + Operator, + Preprocessor, + Type, + Regex, + Variable, + Deleted, // diff removed line + Added, // diff added line + Header, // diff header + Section, // ini section + Heading, // markdown heading + Link, // markdown link + Code, // markdown code + Strong, // markdown bold + Emphasis, // markdown italic + } + + // ── VS Code Dark+ / Light+ colour scheme ────────────────────────────────── + + private static Color Hex(byte r, byte g, byte b) => Color.FromArgb(255, r, g, b); + + public static Color TokenColor(TokenKind kind, bool dark) => dark + ? DarkColor(kind) + : LightColor(kind); + + private static Color DarkColor(TokenKind kind) => kind switch + { + TokenKind.Comment => Hex(0x6A, 0x99, 0x55), + TokenKind.String => Hex(0xCE, 0x91, 0x78), + TokenKind.Keyword => Hex(0x56, 0x9C, 0xD6), + TokenKind.Keyword2 => Hex(0xC5, 0x86, 0xC0), + TokenKind.Number => Hex(0xB5, 0xCE, 0xA8), + TokenKind.Operator => Hex(0xD4, 0xD4, 0xD4), + TokenKind.Preprocessor=> Hex(0x9B, 0x9B, 0x9B), + TokenKind.Type => Hex(0x4E, 0xC9, 0xB0), + TokenKind.Regex => Hex(0xD1, 0x69, 0x69), + TokenKind.Variable => Hex(0x9C, 0xDC, 0xFE), + TokenKind.Deleted => Hex(0xFF, 0x60, 0x60), + TokenKind.Added => Hex(0x80, 0xFF, 0x80), + TokenKind.Header => Hex(0x59, 0x99, 0xFF), + TokenKind.Section => Hex(0x4E, 0xC9, 0xB0), + TokenKind.Heading => Hex(0x56, 0x9C, 0xD6), + TokenKind.Link => Hex(0x9C, 0xDC, 0xFE), + TokenKind.Code => Hex(0xCE, 0x91, 0x78), + TokenKind.Strong => Hex(0xD4, 0xD4, 0xD4), + TokenKind.Emphasis => Hex(0xD4, 0xD4, 0xD4), + _ => Hex(0xD4, 0xD4, 0xD4), + }; + + private static Color LightColor(TokenKind kind) => kind switch + { + TokenKind.Comment => Hex(0x00, 0x80, 0x00), + TokenKind.String => Hex(0xA3, 0x15, 0x15), + TokenKind.Keyword => Hex(0x00, 0x00, 0xFF), + TokenKind.Keyword2 => Hex(0xAF, 0x00, 0xDB), + TokenKind.Number => Hex(0x09, 0x86, 0x58), + TokenKind.Operator => Hex(0x00, 0x00, 0x00), + TokenKind.Preprocessor=> Hex(0xD3, 0x54, 0x00), + TokenKind.Type => Hex(0x26, 0x7F, 0x99), + TokenKind.Regex => Hex(0x81, 0x1F, 0x3F), + TokenKind.Variable => Hex(0x00, 0x10, 0x80), + TokenKind.Deleted => Hex(0x9F, 0x00, 0x00), + TokenKind.Added => Hex(0x00, 0x80, 0x40), + TokenKind.Header => Hex(0x00, 0x00, 0xCC), + TokenKind.Section => Hex(0x26, 0x7F, 0x99), + TokenKind.Heading => Hex(0x00, 0x00, 0xFF), + TokenKind.Link => Hex(0x00, 0x10, 0x80), + TokenKind.Code => Hex(0xA3, 0x15, 0x15), + TokenKind.Strong => Hex(0x00, 0x00, 0x00), + TokenKind.Emphasis => Hex(0x00, 0x00, 0x00), + _ => Hex(0x00, 0x00, 0x00), + }; + + // ── Lexilla language config (for languages handled via SCI_SETLEXERLANGUAGE) ─ + public sealed record LexillaConfig( + string LexerName, + string? Keywords0, + string? Keywords1, + (int StyleId, TokenKind Kind)[]? StyleMap); + + // ── Tier 1: map our IDs to WinUIEdit HighlightingLanguage strings ────────── + // WinUIEdit supports: cpp, csharp, javascript, json, html, xml, yaml, plaintext + + private static readonly Dictionary WinUIEditMap = + new(StringComparer.OrdinalIgnoreCase) + { + // Native WinUIEdit IDs (pass-through) + ["cpp"] = "cpp", + ["csharp"] = "csharp", + ["javascript"] = "javascript", + ["json"] = "json", + ["json5"] = "json", + ["jsonc"] = "json", + ["html"] = "html", + ["xml"] = "xml", + ["xsd"] = "xml", + ["xslt"] = "xml", + ["yaml"] = "yaml", + ["plaintext"] = "plaintext", + ["text"] = "plaintext", + + // C-like → cpp lexer (correct syntax tokenisation, good enough colors) + ["c"] = "cpp", + ["objc"] = "cpp", + ["objectivec"] = "cpp", + ["c++"] = "cpp", + ["java"] = "cpp", + ["dart"] = "cpp", + ["hlsl"] = "cpp", + ["glsl"] = "cpp", + ["cuda"] = "cpp", + ["pawn"] = "cpp", + ["arduino"] = "cpp", + ["verilog"] = "cpp", + ["vhdl"] = "cpp", + ["d"] = "cpp", + ["nim"] = "cpp", + ["zig"] = "cpp", + ["odin"] = "cpp", + ["carbon"] = "cpp", + + // JavaScript-like + ["jsx"] = "javascript", + ["tsx"] = "javascript", + ["vue"] = "html", + ["svelte"] = "html", + ["handlebars"] = "html", + ["ejs"] = "html", + }; + + // ── Tier 2: Lexilla-direct language configs ───────────────────────────────── + + // Style constant arrays use Lexilla/SciLexer.h values. + // Each tuple: (scintilla style id, token kind for colour mapping) + + private static readonly Dictionary LexillaMap = + new(StringComparer.OrdinalIgnoreCase) + { + // ── Python ────────────────────────────────────────────────────── + ["python"] = new("python", + Keywords0: "False None True and as assert async await break class continue def del " + + "elif else except finally for from global if import in is lambda nonlocal " + + "not or pass raise return try while with yield", + Keywords1: "ArithmeticError AssertionError AttributeError BaseException BlockingIOError " + + "BrokenPipeError BufferError BytesWarning ChildProcessError ConnectionAbortedError " + + "ConnectionError ConnectionRefusedError ConnectionResetError DeprecationWarning " + + "EOFError EnvironmentError Exception FileExistsError FileNotFoundError " + + "FloatingPointError FutureWarning GeneratorExit IOError ImportError ImportWarning " + + "IndentationError IndexError InterruptedError IsADirectoryError KeyError " + + "KeyboardInterrupt LookupError MemoryError ModuleNotFoundError NameError " + + "NotADirectoryError NotImplemented NotImplementedError OSError OverflowError " + + "PendingDeprecationWarning PermissionError ProcessLookupError RecursionError " + + "ReferenceError ResourceWarning RuntimeError RuntimeWarning StopAsyncIteration " + + "StopIteration SyntaxError SyntaxWarning SystemError SystemExit TabError " + + "TimeoutError TypeError UnboundLocalError UnicodeDecodeError UnicodeEncodeError " + + "UnicodeError UnicodeTranslateError UnicodeWarning UserWarning ValueError Warning " + + "ZeroDivisionError abs all any ascii bin bool breakpoint bytearray bytes callable " + + "chr classmethod compile complex copyright credits delattr dict dir divmod " + + "enumerate eval exec exit filter float format frozenset getattr globals hasattr " + + "hash help hex id input int isinstance issubclass iter len license list locals " + + "map max memoryview min next object oct open ord pow print property quit range " + + "repr reversed round set setattr slice sorted staticmethod str sum super tuple " + + "type vars zip", + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_P_COMMENTLINE + (2, TokenKind.Number), // SCE_P_NUMBER + (3, TokenKind.String), // SCE_P_STRING + (4, TokenKind.String), // SCE_P_CHARACTER + (5, TokenKind.Keyword), // SCE_P_WORD + (6, TokenKind.String), // SCE_P_TRIPLE + (7, TokenKind.String), // SCE_P_TRIPLEDOUBLE + (8, TokenKind.Type), // SCE_P_CLASSNAME + (9, TokenKind.Type), // SCE_P_DEFNAME + (10, TokenKind.Operator), // SCE_P_OPERATOR + (12, TokenKind.Comment), // SCE_P_COMMENTBLOCK + (13, TokenKind.String), // SCE_P_STRINGEOL + (14, TokenKind.Keyword2), // SCE_P_WORD2 + (15, TokenKind.Preprocessor),// SCE_P_DECORATOR + (16, TokenKind.String), // SCE_P_FSTRING + (17, TokenKind.String), // SCE_P_FCHARACTER + (18, TokenKind.String), // SCE_P_FTRIPLE + (19, TokenKind.String), // SCE_P_FTRIPLEDOUBLE + }), + + // ── Ruby ──────────────────────────────────────────────────────── + ["ruby"] = new("ruby", + Keywords0: "BEGIN END __ENCODING__ __END__ __FILE__ __LINE__ alias and begin break case " + + "class def defined? do else elsif end ensure false for if in module next nil " + + "not or redo rescue retry return self super then true undef unless until when " + + "while yield", + Keywords1: null, + StyleMap: new[] + { + (2, TokenKind.Comment), // SCE_RB_COMMENTLINE + (3, TokenKind.Comment), // SCE_RB_POD + (4, TokenKind.Number), // SCE_RB_NUMBER + (5, TokenKind.Keyword), // SCE_RB_WORD + (6, TokenKind.String), // SCE_RB_STRING + (7, TokenKind.String), // SCE_RB_CHARACTER + (8, TokenKind.Type), // SCE_RB_CLASSNAME + (9, TokenKind.Type), // SCE_RB_DEFNAME + (10, TokenKind.Operator), // SCE_RB_OPERATOR + (12, TokenKind.Regex), // SCE_RB_REGEX + (13, TokenKind.Variable), // SCE_RB_GLOBAL + (14, TokenKind.String), // SCE_RB_SYMBOL + (15, TokenKind.Type), // SCE_RB_MODULE_NAME + (16, TokenKind.Variable), // SCE_RB_INSTANCE_VAR + (17, TokenKind.Variable), // SCE_RB_CLASS_VAR + }), + + // ── Shell / Bash ───────────────────────────────────────────────── + ["bash"] = new("bash", + Keywords0: "break case continue do done elif else esac eval exec exit export fi for " + + "function if in local readonly return set shift source then trap until while", + Keywords1: null, + StyleMap: new[] + { + (2, TokenKind.Comment), // SCE_SH_COMMENTLINE + (3, TokenKind.Number), // SCE_SH_NUMBER + (4, TokenKind.Keyword), // SCE_SH_WORD + (5, TokenKind.String), // SCE_SH_STRING + (6, TokenKind.String), // SCE_SH_CHARACTER + (7, TokenKind.Operator), // SCE_SH_OPERATOR + (9, TokenKind.Variable), // SCE_SH_SCALAR + (10, TokenKind.Variable), // SCE_SH_PARAM + (11, TokenKind.String), // SCE_SH_BACKTICKS + (12, TokenKind.String), // SCE_SH_HERE_DELIM + (13, TokenKind.String), // SCE_SH_HERE_Q + }), + + // ── SQL ────────────────────────────────────────────────────────── + ["sql"] = new("sql", + Keywords0: "abort action add after all alter always analyze and as asc attach autoincrement " + + "before begin between by cascade case cast check collate column commit conflict " + + "constraint create cross current_date current_time current_timestamp database " + + "default deferrable deferred delete detach distinct drop each else end escape " + + "except exclusive exists explain fail for foreign from full glob group having " + + "if ignore immediate in index indexed initially inner insert instead intersect " + + "into is isnull join key left like limit match natural no not notnull null of " + + "offset on or order outer plan pragma primary query raise recursive references " + + "regexp reindex release rename replace restrict right rollback row savepoint " + + "select set table temp temporary then to transaction trigger union unique until " + + "update using vacuum values view virtual when where with without " + + "bigint binary bit blob boolean char character date datetime decimal double " + + "float integer mediumint nchar nvarchar numeric real smallint text tinyint " + + "unsigned varchar year", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_SQL_COMMENT + (2, TokenKind.Comment), // SCE_SQL_COMMENTLINE + (3, TokenKind.Comment), // SCE_SQL_COMMENTDOC + (4, TokenKind.Number), // SCE_SQL_NUMBER + (5, TokenKind.Keyword), // SCE_SQL_WORD + (6, TokenKind.String), // SCE_SQL_STRING + (7, TokenKind.String), // SCE_SQL_CHARACTER + (10, TokenKind.Operator), // SCE_SQL_OPERATOR + (13, TokenKind.Comment), // SCE_SQL_COMMENTLINEDOC + (15, TokenKind.Comment), // SCE_SQL_COMMENTLINEDOC + (16, TokenKind.Keyword2), // SCE_SQL_WORD2 + }), + + // ── CSS ────────────────────────────────────────────────────────── + ["css"] = new("css", + Keywords0: null, + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Type), // SCE_CSS_TAG + (2, TokenKind.Variable), // SCE_CSS_CLASS + (3, TokenKind.Keyword), // SCE_CSS_PSEUDOCLASS + (4, TokenKind.Keyword), // SCE_CSS_UNKNOWN_PSEUDOCLASS + (5, TokenKind.Operator), // SCE_CSS_OPERATOR + (6, TokenKind.Keyword), // SCE_CSS_IDENTIFIER + (8, TokenKind.String), // SCE_CSS_VALUE + (9, TokenKind.Comment), // SCE_CSS_COMMENT + (10, TokenKind.Variable), // SCE_CSS_ID + (11, TokenKind.Keyword2), // SCE_CSS_IMPORTANT + (12, TokenKind.Preprocessor),// SCE_CSS_DIRECTIVE + (13, TokenKind.String), // SCE_CSS_DOUBLESTRING + (14, TokenKind.String), // SCE_CSS_SINGLESTRING + (22, TokenKind.Keyword), // SCE_CSS_MEDIA + (23, TokenKind.Variable), // SCE_CSS_VARIABLE + }), + + // ── PowerShell ─────────────────────────────────────────────────── + ["powershell"] = new("powershell", + Keywords0: "begin break catch class continue data define do dynamicparam else elseif end " + + "exit filter finally for foreach from function if in inlinescript parallel " + + "param process return sequence switch throw trap try until using var while", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_PS_COMMENT + (2, TokenKind.String), // SCE_PS_STRING + (3, TokenKind.String), // SCE_PS_CHARACTER + (4, TokenKind.Number), // SCE_PS_NUMBER + (5, TokenKind.Variable), // SCE_PS_VARIABLE + (6, TokenKind.Operator), // SCE_PS_OPERATOR + (8, TokenKind.Keyword), // SCE_PS_KEYWORD + (9, TokenKind.Type), // SCE_PS_CMDLET + (10, TokenKind.Keyword2), // SCE_PS_ALIAS + (11, TokenKind.Type), // SCE_PS_FUNCTION + (14, TokenKind.Comment), // SCE_PS_COMMENTSTREAM + (15, TokenKind.String), // SCE_PS_HERE_STRING + (16, TokenKind.String), // SCE_PS_HERE_CHARACTER + }), + + // ── Lua ────────────────────────────────────────────────────────── + ["lua"] = new("lua", + Keywords0: "and break do else elseif end false for function goto if in local nil not or " + + "repeat return then true until while", + Keywords1: "assert collectgarbage dofile error _G getmetatable ipairs load loadfile next " + + "pairs pcall print rawequal rawget rawlen rawset require select setmetatable " + + "tonumber tostring type _VERSION xpcall", + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_LUA_COMMENT + (2, TokenKind.Comment), // SCE_LUA_COMMENTLINE + (3, TokenKind.Comment), // SCE_LUA_COMMENTDOC + (4, TokenKind.Number), // SCE_LUA_NUMBER + (5, TokenKind.Keyword), // SCE_LUA_WORD + (6, TokenKind.String), // SCE_LUA_STRING + (7, TokenKind.String), // SCE_LUA_CHARACTER + (8, TokenKind.String), // SCE_LUA_LITERALSTRING + (9, TokenKind.Preprocessor),// SCE_LUA_PREPROCESSOR + (10, TokenKind.Operator), // SCE_LUA_OPERATOR + (13, TokenKind.Keyword2), // SCE_LUA_WORD2 + }), + + // ── R ──────────────────────────────────────────────────────────── + ["r"] = new("r", + Keywords0: "if else repeat while function for in next break TRUE FALSE NULL Inf NaN NA " + + "NA_integer_ NA_real_ NA_complex_ NA_character_", + Keywords1: null, + StyleMap: new[] + { + (2, TokenKind.Comment), // SCE_R_COMMENT + (3, TokenKind.Number), // SCE_R_NUMBER + (4, TokenKind.String), // SCE_R_STRING + (5, TokenKind.String), // SCE_R_STRING2 + (6, TokenKind.Keyword), // SCE_R_KEYWORD + (7, TokenKind.Operator), // SCE_R_OPERATOR + (8, TokenKind.Variable), // SCE_R_IDENTIFIER + }), + + // ── Visual Basic / VB.NET ──────────────────────────────────────── + ["vbnet"] = new("vb", + Keywords0: "AddHandler AddressOf AndAlso Alias And As Boolean ByRef Byte ByVal Call Case " + + "Catch CBool CByte CChar CDate CDbl CDec Char CInt Class CLng CObj Const " + + "Continue CSByte CShort CSng CStr CType CUInt CULng CUShort Date Decimal " + + "Declare Default Delegate Dim DirectCast Do Double Each Else ElseIf End Enum " + + "Equals Error Event Exit False Finally For Friend Function Get GetType " + + "GetXMLNamespace Global GoSub GoTo Handles If Implements Imports In Inherits " + + "Integer Interface Is IsNot Let Lib Like Long Loop Me Mod Module MustInherit " + + "MustOverride MyBase MyClass Namespace Narrowing New Next Not Nothing " + + "NotInheritable NotOverridable Object Of On Operator Option Optional Or OrElse " + + "Out Overloads Overridable Overrides ParamArray Partial Private Property " + + "Protected Public RaiseEvent ReadOnly ReDim RemoveHandler Resume Return SByte " + + "Select Set Shadows Shared Short Single Static Step Stop String Structure Sub " + + "SyncLock Then Throw To True Try TryCast TypeOf UInteger ULong UShort Using " + + "Variant Wend When While Widening With WithEvents WriteOnly Xor", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_B_COMMENT + (2, TokenKind.Number), // SCE_B_NUMBER + (3, TokenKind.Keyword), // SCE_B_KEYWORD + (4, TokenKind.String), // SCE_B_STRING + (5, TokenKind.Preprocessor),// SCE_B_PREPROCESSOR + (6, TokenKind.Operator), // SCE_B_OPERATOR + (9, TokenKind.String), // SCE_B_STRINGEOL + (10, TokenKind.Keyword2), // SCE_B_KEYWORD2 + (11, TokenKind.Type), // SCE_B_KEYWORD3 + }), + + // ── Diff / Patch ───────────────────────────────────────────────── + ["diff"] = new("diff", + Keywords0: null, + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_DIFF_COMMENT + (2, TokenKind.Preprocessor),// SCE_DIFF_COMMAND + (3, TokenKind.Header), // SCE_DIFF_HEADER + (4, TokenKind.Type), // SCE_DIFF_POSITION + (5, TokenKind.Deleted), // SCE_DIFF_DELETED + (6, TokenKind.Added), // SCE_DIFF_ADDED + (7, TokenKind.Keyword), // SCE_DIFF_CHANGED + }), + + // ── Makefile ───────────────────────────────────────────────────── + ["makefile"] = new("makefile", + Keywords0: null, + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_MAKE_COMMENT + (2, TokenKind.Preprocessor),// SCE_MAKE_PREPROCESSOR + (3, TokenKind.Variable), // SCE_MAKE_IDENTIFIER + (4, TokenKind.Operator), // SCE_MAKE_OPERATOR + (5, TokenKind.Type), // SCE_MAKE_TARGET + }), + + // ── Dockerfile ─────────────────────────────────────────────────── + ["dockerfile"] = new("dockerfile", + Keywords0: "ADD ARG CMD COPY ENTRYPOINT ENV EXPOSE FROM HEALTHCHECK LABEL MAINTAINER " + + "ONBUILD RUN SHELL STOPSIGNAL USER VOLUME WORKDIR", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_DOCKERFILE_COMMENT + (2, TokenKind.Keyword), // SCE_DOCKERFILE_INSTRUCTION + (4, TokenKind.String), // SCE_DOCKERFILE_STRING + }), + + // ── TOML ───────────────────────────────────────────────────────── + ["toml"] = new("toml", + Keywords0: "true false inf nan", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_TOML_COMMENT + (2, TokenKind.Variable), // SCE_TOML_IDENTIFIER + (3, TokenKind.Keyword), // SCE_TOML_KEYWORD + (4, TokenKind.Number), // SCE_TOML_NUMBER + (5, TokenKind.String), // SCE_TOML_STRING + (6, TokenKind.Keyword), // SCE_TOML_BOOL + (7, TokenKind.String), // SCE_TOML_DATE + (10, TokenKind.Section), // SCE_TOML_TABLE + (11, TokenKind.Type), // SCE_TOML_KEY + }), + + // ── INI / Properties ───────────────────────────────────────────── + ["ini"] = new("props", + Keywords0: null, + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_PROPS_COMMENT + (2, TokenKind.Section), // SCE_PROPS_SECTION + (3, TokenKind.Operator), // SCE_PROPS_ASSIGNMENT + (4, TokenKind.String), // SCE_PROPS_DEFVAL + (5, TokenKind.Keyword), // SCE_PROPS_KEY + }), + + // ── Batch ──────────────────────────────────────────────────────── + ["batch"] = new("batch", + Keywords0: "call cd chdir cls cmd copy date del dir do echo endlocal erase exit exist for " + + "ftype goto if md mkdir move path pause popd prompt pushd rd rem ren rename " + + "rmdir setlocal shift start time title type ver verify vol", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_BAT_COMMENT + (2, TokenKind.Keyword), // SCE_BAT_WORD + (3, TokenKind.Type), // SCE_BAT_LABEL + (4, TokenKind.Preprocessor),// SCE_BAT_HIDE + (5, TokenKind.Keyword2), // SCE_BAT_COMMAND + (6, TokenKind.Variable), // SCE_BAT_IDENTIFIER + (7, TokenKind.Operator), // SCE_BAT_OPERATOR + }), + + // ── Markdown ───────────────────────────────────────────────────── + ["markdown"] = new("markdown", + Keywords0: null, + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Strong), // SCE_MARKDOWN_STRONG1 + (2, TokenKind.Strong), // SCE_MARKDOWN_STRONG2 + (3, TokenKind.Emphasis), // SCE_MARKDOWN_EM1 + (4, TokenKind.Emphasis), // SCE_MARKDOWN_EM2 + (5, TokenKind.Heading), // SCE_MARKDOWN_HEADER1 + (6, TokenKind.Heading), // SCE_MARKDOWN_HEADER2 + (7, TokenKind.Heading), // SCE_MARKDOWN_HEADER3 + (8, TokenKind.Heading), // SCE_MARKDOWN_HEADER4 + (9, TokenKind.Heading), // SCE_MARKDOWN_HEADER5 + (10, TokenKind.Heading), // SCE_MARKDOWN_HEADER6 + (12, TokenKind.String), // SCE_MARKDOWN_ULIST_ITEM + (13, TokenKind.String), // SCE_MARKDOWN_OLIST_ITEM + (14, TokenKind.Comment), // SCE_MARKDOWN_BLOCKQUOTE + (16, TokenKind.Operator), // SCE_MARKDOWN_HRULE + (17, TokenKind.Link), // SCE_MARKDOWN_LINK + (18, TokenKind.Code), // SCE_MARKDOWN_CODE + (19, TokenKind.Code), // SCE_MARKDOWN_CODE2 + (20, TokenKind.Code), // SCE_MARKDOWN_CODEBK + }), + + // ── Perl ───────────────────────────────────────────────────────── + ["perl"] = new("perl", + Keywords0: "abs accept alarm and atan2 BEGIN bind binmode bless break caller chdir chmod " + + "chomp chop chown chr chroot close closedir cmp connect continue cos crypt " + + "dbmclose dbmopen defined delete die do dump each elsif END else eq eval exec " + + "exists exit exp fcntl fileno flock for foreach fork format given glob goto " + + "grep hex if index int ioctl join keys kill last lc lcfirst le length link " + + "listen local localtime lock log lstat lt map mkdir msgctl msgget msgsnd " + + "msgrcv my ne next no not oct open opendir or ord our pack package pipe pop " + + "pos print printf prototype push q qq qr qw qx rand read readdir readline " + + "readlink readpipe recv redo ref rename require reset return reverse rewinddir " + + "rindex rmdir say scalar seek seekdir select semctl semget semop send setpgrp " + + "setpriority setsockopt shift shmctl shmget shmread shmwrite shutdown sin " + + "sleep socket socketpair sort splice split sprintf sqrt srand stat study " + + "substr symlink syscall sysopen sysread sysseek system syswrite tell telldir " + + "tie tied time times truncate uc ucfirst umask undef unless unlink unpack " + + "unshift untie until use utime values vec wait waitpid wantarray warn when " + + "while write", + Keywords1: null, + StyleMap: new[] + { + (2, TokenKind.Comment), // SCE_PL_COMMENTLINE + (4, TokenKind.Number), // SCE_PL_NUMBER + (5, TokenKind.Keyword), // SCE_PL_WORD + (6, TokenKind.String), // SCE_PL_STRING + (7, TokenKind.String), // SCE_PL_CHARACTER + (8, TokenKind.Preprocessor),// SCE_PL_PREPROCESSOR + (10, TokenKind.Operator), // SCE_PL_OPERATOR + (12, TokenKind.Regex), // SCE_PL_REGEX + (17, TokenKind.Variable), // SCE_PL_SCALAR + (18, TokenKind.Variable), // SCE_PL_ARRAY + (19, TokenKind.Variable), // SCE_PL_HASH + (20, TokenKind.Variable), // SCE_PL_SYMBOLTABLE + }), + + // ── Go (uses cpp lexer but with Go keywords for richer highlighting) + ["go"] = new("cpp", + Keywords0: "break case chan const continue default defer else fallthrough for func go " + + "goto if import interface map package range return select struct switch type var", + Keywords1: "bool byte complex64 complex128 error float32 float64 int int8 int16 int32 " + + "int64 rune string uint uint8 uint16 uint32 uint64 uintptr", + StyleMap: null), // Colors handled by WinUIEdit cpp color scheme + + // ── Rust ───────────────────────────────────────────────────────── + ["rust"] = new("cpp", + Keywords0: "as async await break const continue crate dyn else enum extern false fn for " + + "if impl in let loop match mod move mut pub ref return self Self static struct " + + "super trait true type union unsafe use where while", + Keywords1: "bool char f32 f64 i8 i16 i32 i64 i128 isize str u8 u16 u32 u64 u128 usize " + + "String Vec Option Result Box Arc Rc", + StyleMap: null), + + // ── Swift ──────────────────────────────────────────────────────── + ["swift"] = new("cpp", + Keywords0: "actor any as associatedtype associativity async await break case catch class " + + "continue convenience default defer deinit distributed do dynamic else enum " + + "extension fallthrough false fileprivate final for func get guard if import " + + "in indirect infix init inout internal is isolated lazy left let mutating " + + "nonisolated none nonmutating open operator optional override postfix " + + "precedencegroup prefix private protocol public repeat required rethrows " + + "return right self Self setter_access some static struct subscript super " + + "switch throw throws true try typealias unowned unsafe var weak where while", + Keywords1: "Bool Character Double Float Int Int8 Int16 Int32 Int64 Optional String UInt " + + "UInt8 UInt16 UInt32 UInt64 Void Array Dictionary Set", + StyleMap: null), + + // ── Kotlin ─────────────────────────────────────────────────────── + ["kotlin"] = new("cpp", + Keywords0: "abstract actual annotation as break by catch class companion const constructor " + + "continue crossinline data do dynamic else enum expect external false final " + + "finally for fun get if import in infixline init inline inner interface " + + "internal is it lateinit noinline null object open operator out override " + + "package private protected public reified return sealed set super suspend " + + "tailrec this throw true try typealias typeof val var vararg when where while", + Keywords1: "Boolean Byte Char Double Float Int Long Short String Unit Any Nothing", + StyleMap: null), + + // ── Scala ──────────────────────────────────────────────────────── + ["scala"] = new("cpp", + Keywords0: "abstract case catch class def do else extends false final finally for " + + "forSome if implicit import lazy match new null object override package " + + "private protected return sealed super this throw trait true try type val " + + "var while with yield", + Keywords1: "AnyRef AnyVal Boolean Byte Char Double Float Int Long Nothing Null Short " + + "String Unit", + StyleMap: null), + + // ── Java ───────────────────────────────────────────────────────── + ["java"] = new("cpp", + Keywords0: "abstract assert boolean break byte case catch char class const continue " + + "default do double else enum extends final finally float for goto if " + + "implements import instanceof int interface long native new package private " + + "protected public record return sealed short static strictfp super switch " + + "synchronized this throw throws transient try var void volatile while", + Keywords1: "Boolean Byte Character Double Float Integer Long Object Short String Void", + StyleMap: null), + + // ── TypeScript ─────────────────────────────────────────────────── + ["typescript"] = new("cpp", + Keywords0: "abstract accessor any as asserts async at await bigint boolean break case " + + "catch class const constructor continue debugger declare default delete do " + + "else enum export extends false finally for from function get global if " + + "implements import in infer instanceof interface intrinsic is keyof let " + + "module namespace never new null number object of out override package " + + "private protected public readonly require return satisfies set static " + + "string super switch symbol this throw true try type typeof undefined " + + "unique unknown until using var void while with yield", + Keywords1: null, + StyleMap: null), + + // ── PHP ────────────────────────────────────────────────────────── + ["php"] = new("cpp", + Keywords0: "abstract and array as break callable case catch class clone const continue " + + "declare default do echo else elseif empty enddeclare endfor endforeach " + + "endif endswitch endwhile enum eval exit extends final finally fn for " + + "foreach function global goto if implements include include_once instanceof " + + "insteadof interface isset list match namespace new or print private " + + "protected public readonly require require_once return static switch throw " + + "trait try unset use var while xor yield", + Keywords1: null, + StyleMap: null), + + // ── Dart ───────────────────────────────────────────────────────── + ["dart"] = new("cpp", + Keywords0: "abstract as assert async await break case catch class const continue " + + "covariant default deferred do dynamic else enum export extends extension " + + "external factory false final finally for function get hide if implements " + + "import in interface is late library mixin new null of on operator part " + + "required rethrow return sealed set show static super switch sync this " + + "throw true try typedef var void when while with yield", + Keywords1: "bool double dynamic int num object string void", + StyleMap: null), + + // ── F# ─────────────────────────────────────────────────────────── + ["fsharp"] = new("cpp", + Keywords0: "abstract and as assert async base begin class default delegate do done " + + "downcast downto elif else end exception extern false finally fixed for " + + "fun function global if in inherit inline interface internal lazy let " + + "match member module mutable namespace new not null of open or override " + + "private public rec return sig static struct then to true try type upcast " + + "use val void when while with yield", + Keywords1: "bool byte char decimal double float float32 float64 int int8 int16 int32 " + + "int64 nativeint obj sbyte single string uint uint8 uint16 uint32 uint64 " + + "unativeint unit", + StyleMap: null), + + // ── Elixir ─────────────────────────────────────────────────────── + ["elixir"] = new("cpp", + Keywords0: "after alias and case catch cond def defcallback defdelegate defexception " + + "defguard defimpl defmacro defmacrop defmodule defoverridable defp " + + "defprotocol defstruct do else end fn for if import in not or quote raise " + + "receive require rescue try unless unquote unquote_splicing use when with", + Keywords1: null, + StyleMap: null), + + // ── Haskell ────────────────────────────────────────────────────── + ["haskell"] = new("cpp", + Keywords0: "case class data default deriving do else forall foreign hiding if import " + + "in infix infixl infixr instance let module newtype of qualified then type " + + "where", + Keywords1: "Bool Char Double Float IO Int Integer Maybe Ordering String", + StyleMap: null), + + // ── CMake ──────────────────────────────────────────────────────── + ["cmake"] = new("cmake", + Keywords0: "add_compile_definitions add_compile_options add_custom_command " + + "add_custom_target add_definitions add_dependencies add_executable " + + "add_library add_subdirectory add_test cmake_minimum_required " + + "configure_file enable_language enable_testing execute_process " + + "file find_file find_library find_package find_path find_program " + + "foreach function get_cmake_property get_directory_property " + + "get_filename_component get_property get_source_file_property " + + "get_target_property if include include_directories install list " + + "macro math message option project set set_directory_properties " + + "set_property set_source_files_properties set_target_properties " + + "set_tests_properties string target_compile_definitions " + + "target_compile_features target_compile_options target_include_directories " + + "target_link_directories target_link_libraries target_link_options " + + "target_sources while", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), // SCE_CMAKE_COMMENT + (2, TokenKind.String), // SCE_CMAKE_STRINGDQ + (3, TokenKind.String), // SCE_CMAKE_STRINGSQ + (4, TokenKind.String), // SCE_CMAKE_STRINGLQ + (5, TokenKind.Variable), // SCE_CMAKE_VARIABLE + (6, TokenKind.Number), // SCE_CMAKE_NUMBER + (8, TokenKind.Keyword), // SCE_CMAKE_WORD + (9, TokenKind.Keyword2), // SCE_CMAKE_COMMANDS_DEPRECATED + (12, TokenKind.Variable), // SCE_CMAKE_VARIABLE2 + }), + }; + + // ── Aliases ─────────────────────────────────────────────────────────────── + + private static readonly Dictionary LexillaAliases = + new(StringComparer.OrdinalIgnoreCase) + { + ["sh"] = "bash", + ["zsh"] = "bash", + ["ksh"] = "bash", + ["fish"] = "bash", + ["shellscript"]= "bash", + ["shell"] = "bash", + ["scss"] = "css", + ["less"] = "css", + ["sass"] = "css", + ["vb"] = "vbnet", + ["visualbasic"]= "vbnet", + ["patch"] = "diff", + ["properties"] = "ini", + ["cfg"] = "ini", + ["conf"] = "ini", + ["env"] = "ini", + ["cmd"] = "batch", + ["latex"] = "cpp", // close enough for tex + ["tex"] = "cpp", + ["tsx"] = "typescript", + }; + + // ── Public API ──────────────────────────────────────────────────────────── + + /// + /// Returns the WinUIEdit HighlightingLanguage string for a given language ID, + /// or null if the language should be handled via Lexilla directly. + /// + public static string? GetWinUIEditId(string languageId) + { + if (string.IsNullOrEmpty(languageId)) return "plaintext"; + + // Resolve alias first + string resolved = LexillaAliases.TryGetValue(languageId, out string? alias) + ? alias + : languageId; + + // Direct WinUIEdit mapping + if (WinUIEditMap.TryGetValue(resolved, out string? winuiId)) + return winuiId; + + // Lexilla-direct languages that ultimately use cpp lexer return winuiId="cpp" + if (LexillaMap.TryGetValue(resolved, out LexillaConfig? cfg) && + cfg.LexerName == "cpp") + { + return "cpp"; // WinUIEdit handles cpp colors + } + + return null; // Use Lexilla directly + } + + /// + /// Returns the Lexilla config for a given language ID, + /// or null if not handled via Lexilla. + /// + public static LexillaConfig? GetLexillaConfig(string languageId) + { + if (string.IsNullOrEmpty(languageId)) return null; + + string resolved = LexillaAliases.TryGetValue(languageId, out string? alias) + ? alias + : languageId; + + return LexillaMap.TryGetValue(resolved, out LexillaConfig? cfg) ? cfg : null; + } + + /// + /// Returns the custom keyword sets for a WinUIEdit-mapped language + /// (e.g., Java/Go/Rust mapped to "cpp" but needing different keywords). + /// Returns null if no keyword override is needed. + /// + public static (string? Keywords0, string? Keywords1)? GetKeywordOverride(string languageId) + { + if (string.IsNullOrEmpty(languageId)) return null; + + string resolved = LexillaAliases.TryGetValue(languageId, out string? alias) + ? alias + : languageId; + + // Only return keyword overrides for languages that WinUIEdit handles as "cpp" + // but that need different keywords (Java, Go, Rust, etc.) + if (LexillaMap.TryGetValue(resolved, out LexillaConfig? cfg) && + cfg.LexerName == "cpp" && + (cfg.Keywords0 != null || cfg.Keywords1 != null)) + { + return (cfg.Keywords0, cfg.Keywords1); + } + + return null; + } +} diff --git a/JitHub.WinUI/Views/Pages/RepoCodePage.xaml b/JitHub.WinUI/Views/Pages/RepoCodePage.xaml index 0c5e31d..4c5f1ca 100644 --- a/JitHub.WinUI/Views/Pages/RepoCodePage.xaml +++ b/JitHub.WinUI/Views/Pages/RepoCodePage.xaml @@ -2,56 +2,73 @@ x:Class="JitHub.WinUI.Views.Pages.RepoCodePage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:local="using:JitHub.WinUI.Views.Pages" - xmlns:ui="using:CommunityToolkit.WinUI.UI" xmlns:muxc="using:Microsoft.UI.Xaml.Controls" + xmlns:cv="using:JitHub.WinUI.Views.Controls.CodeViewer" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:controls="using:Microsoft.UI.Xaml.Controls" mc:Ignorable="d" Background="Transparent"> - - - - - - - + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + - diff --git a/JitHub.WinUI/Views/Pages/RepoCodePage.xaml.cs b/JitHub.WinUI/Views/Pages/RepoCodePage.xaml.cs index f789739..4084e8c 100644 --- a/JitHub.WinUI/Views/Pages/RepoCodePage.xaml.cs +++ b/JitHub.WinUI/Views/Pages/RepoCodePage.xaml.cs @@ -1,773 +1,103 @@ using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Threading.Tasks; +using System.ComponentModel; +using System.Threading; using JitHub.Models.NavArgs; -using JitHub.Services; -using Microsoft.UI.Xaml.Navigation; -using CommunityToolkit.WinUI.UI.Helpers; +using JitHub.WinUI.ViewModels.CodeViewer; using Microsoft.UI.Xaml; -using Microsoft.Web.WebView2.Core; - -namespace JitHub.WinUI.Views.Pages -{ - public sealed partial class RepoCodePage : Microsoft.UI.Xaml.Controls.Page - { - private const string HostGitHubTokenMarker = "__jithub_host_token__"; - private const int HeaderNotFoundHResult = unchecked((int)0x80070490); - private const string PublicPreviewOwner = "JitHubApp"; - private const string PublicPreviewRepository = "JitHubV2"; - private const string PublicPreviewBranch = "main"; - private const long PublicPreviewRepositoryId = 623352671; - private static readonly IReadOnlyDictionary PublicPreviewFiles = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["README.md"] = """ - # JitHubV2 - - JitHub is a native WinUI GitHub client for Windows. The preview capture uses this public repository to show the real code experience without relying on GitHub API rate limits. - - ## Highlights - - - Multiple repository tabs - - Pull request conversations - - Embedded code browsing - - Light and dark theme support - """, - ["JitHub.WinUI/App.xaml.cs"] = """ - using Microsoft.UI.Xaml; - - namespace JitHub.WinUI; - - public partial class App : Application - { - public App() - { - InitializeComponent(); - } - } - """, - ["JitHub.WinUI/Views/Pages/ShellPage.xaml.cs"] = """ - using Microsoft.UI.Xaml.Controls; - - namespace JitHub.WinUI.Views.Pages; - - public sealed partial class ShellPage : Page - { - public ShellPage() - { - InitializeComponent(); - } - } - """, - ["JitHub.Web/Pages/Home.razor"] = """ - @page "/" - -
- JitHub app home -

JitHub

-

A native GitHub client for Windows.

-
- """, - ["JitHub.Web/wwwroot/css/site.css"] = """ - :root { - color-scheme: light dark; - --accent: #2563eb; - } - - .hero { - min-height: 100vh; - display: grid; - place-items: center; - } - """, - ["JitHub.WinUI/JitHub.WinUI.csproj"] = """ - - - net10.0-windows10.0.26100.0 - true - - - """ - }; - private static readonly string[] PublicPreviewDirectories = - [ - "JitHub.WinUI", - "JitHub.WinUI/Views", - "JitHub.WinUI/Views/Pages", - "JitHub.Web", - "JitHub.Web/Pages", - "JitHub.Web/wwwroot", - "JitHub.Web/wwwroot/css" - ]; - private readonly App _app = (App)Application.Current; - private readonly ThemeListener _themeListener = new(); - private readonly GlobalViewModel _globalViewModel; - private readonly IAccountService _accountService; - private readonly IAuthService _authService; - private readonly EditorAssetService _editorAssetService; - private readonly IGitHubClientService _gitHubClientService; - private string? _editorBootstrapScriptId; - private bool _gitHubRequestBridgeInitialized; - private string? _editorAccessToken; - private readonly Queue _previewResponseStreams = new(); - - public RepoCodePage() - { - _globalViewModel = _app.GetService(); - _accountService = _app.GetService(); - _authService = _app.GetService(); - _editorAssetService = _app.GetService(); - _gitHubClientService = _app.GetService(); - this.InitializeComponent(); - ShellWebView.NavigationStarting += OnNavigationStarting; - ShellWebView.NavigationCompleted += OnNavigationCompleted; - } - - override protected async void OnNavigatedTo(NavigationEventArgs e) - { - base.OnNavigatedTo(e); - try - { - await InitializeEditorAsync((CodeViewerNavArg)e.Parameter); - } - catch (Exception ex) - { - ShowError(ex.Message); - } - } - - private async Task InitializeEditorAsync(CodeViewerNavArg arg) - { - ArgumentNullException.ThrowIfNull(arg); - - SetLoadingState(); - await Task.Yield(); - - var theme = _themeListener.CurrentTheme == ApplicationTheme.Light ? "light" : "dark"; - var token = _authService.GetToken(_authService.AuthenticatedUser?.Id ?? _accountService.GetUser()); - if (string.IsNullOrWhiteSpace(token)) - { - throw new InvalidOperationException("An authenticated GitHub session is required to open the embedded code view."); - } - - _editorAccessToken = token; - Task editorAssetPathTask = _editorAssetService.GetEditorRootPathAsync(); - Task ensureWebViewTask = ShellWebView.EnsureCoreWebView2Async().AsTask(); - await Task.WhenAll(editorAssetPathTask, ensureWebViewTask); - string editorAssetPath = await editorAssetPathTask; - - EnsureGitHubRequestBridge(); - - string encodedTokenMarker = JavaScriptEncoder.Default.Encode(HostGitHubTokenMarker); - if (!string.IsNullOrWhiteSpace(_editorBootstrapScriptId)) - { - ShellWebView.CoreWebView2.RemoveScriptToExecuteOnDocumentCreated(_editorBootstrapScriptId); - } +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; - string bootstrapScript = - "if (window.top === window && window.location && window.location.protocol === 'https:' && window.location.host === 'jithub.local') {" + - $"Object.defineProperty(window, '__jithubBootstrap', {{ configurable: false, enumerable: false, writable: false, value: Object.freeze({{ githubToken: \"{encodedTokenMarker}\" }}) }});" + - "}"; - _editorBootstrapScriptId = await ShellWebView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(bootstrapScript); - ShellWebView.CoreWebView2.SetVirtualHostNameToFolderMapping( - "jithub.local", - editorAssetPath, - CoreWebView2HostResourceAccessKind.Allow); - ShellWebView.CoreWebView2.Settings.AreDevToolsEnabled = _globalViewModel.DevMode; - var repo = arg.Repo ?? throw new InvalidOperationException("Repository context is required to open the embedded code view."); - var ownerLogin = repo.Owner?.Login; - if (string.IsNullOrWhiteSpace(ownerLogin) || string.IsNullOrWhiteSpace(repo.Name)) - { - throw new InvalidOperationException("Repository metadata is incomplete for the embedded code view."); - } +namespace JitHub.WinUI.Views.Pages; - string? gitRef = arg.IsBranch ? arg.Branch : arg.GitRef; - if (string.IsNullOrWhiteSpace(gitRef)) - { - gitRef = repo.DefaultBranch; - } +public sealed partial class RepoCodePage : Page +{ + private readonly App _app = (App)Application.Current; + private CancellationTokenSource? _initCts; - if (string.IsNullOrWhiteSpace(gitRef)) - { - var latestRepo = await _gitHubClientService.GetRepositoryAsync(token, ownerLogin, repo.Name); - if (!string.IsNullOrWhiteSpace(latestRepo.DefaultBranch)) - { - repo = latestRepo; - arg.WithRepo(latestRepo); - gitRef = latestRepo.DefaultBranch; - } - } + public RepoCodePageViewModel ViewModel { get; } - if (string.IsNullOrWhiteSpace(gitRef)) - { - throw new InvalidOperationException("JitHub could not determine which branch to open for this repository."); - } + public RepoCodePage() + { + ViewModel = _app.GetService(); + InitializeComponent(); + ViewModel.PropertyChanged += OnViewModelPropertyChanged; + } - ShellWebView.CoreWebView2.Navigate( - $"https://jithub.local/index.html?ref={Uri.EscapeDataString(gitRef)}&owner={Uri.EscapeDataString(ownerLogin)}&repo={Uri.EscapeDataString(repo.Name)}&theme={Uri.EscapeDataString(theme)}"); - } + protected override async void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); - private async void OnNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs args) + if (e.Parameter is not CodeViewerNavArg arg || arg.Repo is null) { - try - { - if (!args.IsSuccess) - { - ShowError($"The embedded editor failed to load ({args.WebErrorStatus})."); - return; - } - - await Task.Delay(200); - LoadingIndicator.Visibility = Visibility.Collapsed; - LoadingIndicator.IsActive = false; - ErrorState.Visibility = Visibility.Collapsed; - WebViewContainer.Visibility = Visibility.Visible; - } - catch (Exception ex) - { - ShowError(ex.Message); - } + ShowError("Repository context is required to open the code viewer."); + return; } - private void SetLoadingState() + var repo = arg.Repo; + var owner = repo.Owner?.Login; + var name = repo.Name; + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(name)) { - ErrorState.Visibility = Visibility.Collapsed; - ErrorText.Text = string.Empty; - WebViewContainer.Visibility = Visibility.Collapsed; - LoadingIndicator.Visibility = Visibility.Visible; - LoadingIndicator.IsActive = true; + ShowError("Repository metadata is incomplete."); + return; } - private void ShowError(string message) + string? gitRef = arg.IsBranch ? arg.Branch : arg.GitRef; + if (string.IsNullOrWhiteSpace(gitRef)) { - LoadingIndicator.Visibility = Visibility.Collapsed; - LoadingIndicator.IsActive = false; - WebViewContainer.Visibility = Visibility.Collapsed; - ErrorText.Text = message; - ErrorState.Visibility = Visibility.Visible; + gitRef = repo.DefaultBranch; } - private async void OnNavigationStarting(Microsoft.UI.Xaml.Controls.WebView2 sender, CoreWebView2NavigationStartingEventArgs args) + if (string.IsNullOrWhiteSpace(gitRef)) { - try - { - if (!Uri.TryCreate(args.Uri, UriKind.Absolute, out Uri? uri)) - { - return; - } - - if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && - string.Equals(uri.Host, "jithub.local", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - args.Cancel = true; - await Windows.System.Launcher.LaunchUriAsync(uri); - } - catch (Exception ex) - { - ShowError(ex.Message); - } + ShowError("Could not determine which branch to load."); + return; } - private void EnsureGitHubRequestBridge() - { - if (_gitHubRequestBridgeInitialized) - { - return; - } - - ShellWebView.CoreWebView2.AddWebResourceRequestedFilter("https://api.github.com/*", CoreWebView2WebResourceContext.All); - ShellWebView.CoreWebView2.AddWebResourceRequestedFilter("https://raw.githubusercontent.com/*", CoreWebView2WebResourceContext.All); - ShellWebView.CoreWebView2.WebResourceRequested += OnGitHubWebResourceRequested; - _gitHubRequestBridgeInitialized = true; - } + _initCts?.Cancel(); + _initCts?.Dispose(); + _initCts = new CancellationTokenSource(); - private void OnGitHubWebResourceRequested(object? sender, CoreWebView2WebResourceRequestedEventArgs args) + try { - if (string.IsNullOrWhiteSpace(_editorAccessToken) || - !Uri.TryCreate(args.Request.Uri, UriKind.Absolute, out Uri? uri)) - { - return; - } - - if (!string.Equals(uri.Host, "api.github.com", StringComparison.OrdinalIgnoreCase) && - !string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (GitHubClientService.IsPublicAccessToken(_editorAccessToken)) - { - if (string.Equals(args.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) - { - args.Response = CreateTextResponse(string.Empty, "text/plain", 204, "No Content"); - return; - } - - if (TryCreatePublicPreviewResponse(uri, out string responseBody, out string contentType)) - { - args.Response = CreateTextResponse(responseBody, contentType); - return; - } - } - - string authorizationHeader = GetRequestHeader(args.Request.Headers, "Authorization"); - if (authorizationHeader.Contains(HostGitHubTokenMarker, StringComparison.Ordinal)) - { - if (GitHubClientService.IsPublicAccessToken(_editorAccessToken)) - { - args.Request.Headers.RemoveHeader("Authorization"); - return; - } - - args.Request.Headers.SetHeader( - "Authorization", - authorizationHeader.Replace(HostGitHubTokenMarker, _editorAccessToken, StringComparison.Ordinal)); - return; - } - - string? markerToken = GetTokenQueryParameter(uri); - if (string.Equals(markerToken, HostGitHubTokenMarker, StringComparison.Ordinal)) - { - if (GitHubClientService.IsPublicAccessToken(_editorAccessToken)) - { - return; - } - - args.Request.Headers.SetHeader("Authorization", $"token {_editorAccessToken}"); - } + await ViewModel.InitializeAsync(owner!, name!, gitRef!, _initCts.Token); } - - private CoreWebView2WebResourceResponse CreateTextResponse( - string body, - string contentType, - int statusCode = 200, - string reasonPhrase = "OK") + catch (OperationCanceledException) { } + catch (Exception ex) { - var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - _previewResponseStreams.Enqueue(stream); - while (_previewResponseStreams.Count > 40) - { - _previewResponseStreams.Dequeue().Dispose(); - } - - return ShellWebView.CoreWebView2.Environment.CreateWebResourceResponse( - stream.AsRandomAccessStream(), - statusCode, - reasonPhrase, - $"Content-Type: {contentType}; charset=utf-8\r\n" + - "Access-Control-Allow-Origin: *\r\n" + - "Access-Control-Allow-Methods: GET, OPTIONS\r\n" + - "Access-Control-Allow-Headers: authorization, content-type, accept, x-github-api-version\r\n" + - "Cache-Control: no-store\r\n"); - } - - private static bool TryCreatePublicPreviewResponse(Uri uri, out string responseBody, out string contentType) - { - responseBody = string.Empty; - contentType = "application/json"; - - string[] pathSegments = Uri.UnescapeDataString(uri.AbsolutePath) - .Trim('/') - .Split('/', StringSplitOptions.RemoveEmptyEntries); - - if (string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase)) - { - if (pathSegments.Length >= 4 && - IsPublicPreviewRepository(pathSegments[0], pathSegments[1])) - { - string filePath = string.Join('/', pathSegments[3..]); - responseBody = GetPublicPreviewFileContent(filePath); - contentType = GetContentType(filePath); - return true; - } - - return false; - } - - if (pathSegments.Length < 3 || - !string.Equals(pathSegments[0], "repos", StringComparison.OrdinalIgnoreCase) || - !IsPublicPreviewRepository(pathSegments[1], pathSegments[2])) - { - return false; - } - - if (pathSegments.Length == 3) - { - responseBody = SerializePublicPreviewRepository(); - return true; - } - - if (pathSegments.Length >= 5 && - string.Equals(pathSegments[3], "branches", StringComparison.OrdinalIgnoreCase) && - string.Equals(pathSegments[4], PublicPreviewBranch, StringComparison.OrdinalIgnoreCase)) - { - responseBody = JsonSerializer.Serialize(new - { - name = PublicPreviewBranch, - commit = new - { - sha = "preview-main-sha", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/commits/preview-main-sha" - }, - protected_branch = false - }); - return true; - } - - if (pathSegments.Length >= 7 && - string.Equals(pathSegments[3], "git", StringComparison.OrdinalIgnoreCase) && - (string.Equals(pathSegments[4], "ref", StringComparison.OrdinalIgnoreCase) || - string.Equals(pathSegments[4], "refs", StringComparison.OrdinalIgnoreCase)) && - string.Equals(pathSegments[5], "heads", StringComparison.OrdinalIgnoreCase) && - string.Equals(pathSegments[6], PublicPreviewBranch, StringComparison.OrdinalIgnoreCase)) - { - responseBody = JsonSerializer.Serialize(new - { - @ref = $"refs/heads/{PublicPreviewBranch}", - node_id = "preview-ref-main", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/ref/heads/{PublicPreviewBranch}", - @object = new - { - sha = "preview-main-sha", - type = "commit", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/commits/preview-main-sha" - } - }); - return true; - } - - if (pathSegments.Length >= 6 && - string.Equals(pathSegments[3], "git", StringComparison.OrdinalIgnoreCase) && - string.Equals(pathSegments[4], "matching-refs", StringComparison.OrdinalIgnoreCase)) - { - responseBody = JsonSerializer.Serialize(new[] - { - new - { - @ref = $"refs/heads/{PublicPreviewBranch}", - node_id = "preview-matching-ref-main", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/ref/heads/{PublicPreviewBranch}", - @object = new - { - sha = "preview-main-sha", - type = "commit", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/commits/preview-main-sha" - } - } - }); - return true; - } - - if (pathSegments.Length >= 6 && - string.Equals(pathSegments[3], "git", StringComparison.OrdinalIgnoreCase) && - string.Equals(pathSegments[4], "trees", StringComparison.OrdinalIgnoreCase)) - { - responseBody = SerializePublicPreviewTree(GetTreePath(pathSegments), IsRecursiveTreeRequest(uri)); - return true; - } - - if (pathSegments.Length >= 5 && - string.Equals(pathSegments[3], "contents", StringComparison.OrdinalIgnoreCase)) - { - string contentPath = string.Join('/', pathSegments[4..]); - responseBody = SerializePublicPreviewContents(contentPath); - return true; - } - - if (pathSegments.Length >= 4 && - string.Equals(pathSegments[3], "readme", StringComparison.OrdinalIgnoreCase)) - { - responseBody = SerializePublicPreviewFile("README.md"); - return true; - } - - if (pathSegments.Length >= 5 && - string.Equals(pathSegments[3], "commits", StringComparison.OrdinalIgnoreCase)) - { - responseBody = JsonSerializer.Serialize(new - { - sha = "preview-main-sha", - html_url = $"https://github.com/{PublicPreviewOwner}/{PublicPreviewRepository}/commit/preview-main-sha", - commit = new - { - message = "Refresh website screenshots", - author = new { name = "JitHub", date = "2026-05-04T17:10:00Z" } - } - }); - return true; - } - - return false; + ShowError(ex.Message); } + } - private static bool IsPublicPreviewRepository(string owner, string repo) - { - return string.Equals(owner, PublicPreviewOwner, StringComparison.OrdinalIgnoreCase) && - string.Equals(repo, PublicPreviewRepository, StringComparison.OrdinalIgnoreCase); - } + protected override void OnNavigatedFrom(NavigationEventArgs e) + { + _initCts?.Cancel(); + _initCts?.Dispose(); + _initCts = null; + base.OnNavigatedFrom(e); + } - private static string SerializePublicPreviewRepository() + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(RepoCodePageViewModel.LoadError)) { - return JsonSerializer.Serialize(new + DispatcherQueue.TryEnqueue(() => { - id = PublicPreviewRepositoryId, - node_id = "R_kgDOJTgCXw", - name = PublicPreviewRepository, - full_name = $"{PublicPreviewOwner}/{PublicPreviewRepository}", - @private = false, - html_url = $"https://github.com/{PublicPreviewOwner}/{PublicPreviewRepository}", - description = "GitHub WinUI Client", - fork = false, - language = "C#", - stargazers_count = 146, - watchers_count = 146, - forks_count = 15, - open_issues_count = 8, - default_branch = PublicPreviewBranch, - owner = new + if (!string.IsNullOrEmpty(ViewModel.LoadError)) { - login = PublicPreviewOwner, - id = 170190931, - avatar_url = "https://avatars.githubusercontent.com/u/170190931?v=4", - html_url = $"https://github.com/{PublicPreviewOwner}" + ShowError(ViewModel.LoadError!); } - }); - } - - private static string SerializePublicPreviewTree(string requestedPath, bool recursive) - { - var treeEntries = CreatePublicPreviewTreeEntries(requestedPath, recursive).ToList(); - - return JsonSerializer.Serialize(new - { - sha = string.IsNullOrWhiteSpace(requestedPath) ? "preview-root-tree" : $"preview-tree-{requestedPath.GetHashCode():x}", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/trees/preview-root-tree", - tree = treeEntries, - truncated = false - }); - } - - private static IEnumerable CreatePublicPreviewTreeEntries(string requestedPath, bool recursive) - { - string normalizedPath = requestedPath.Trim('/'); - string prefix = string.IsNullOrWhiteSpace(normalizedPath) ? string.Empty : normalizedPath + "/"; - var allPaths = PublicPreviewDirectories - .Select(path => (Path: path, Type: "tree")) - .Concat(PublicPreviewFiles.Keys.Select(path => (Path: path, Type: "blob"))) - .Where(item => string.IsNullOrWhiteSpace(prefix) || - item.Path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - - if (!recursive) - { - allPaths = allPaths - .Select(item => (Path: string.IsNullOrWhiteSpace(prefix) ? item.Path : item.Path[prefix.Length..], item.Type)) - .Where(item => item.Path.Length > 0 && !item.Path.Contains('/')) - .Select(item => (Path: string.IsNullOrWhiteSpace(prefix) ? item.Path : prefix + item.Path, item.Type)); - } - - foreach ((string path, string type) in allPaths) - { - string responsePath = string.IsNullOrWhiteSpace(prefix) ? path : path[prefix.Length..]; - if (string.IsNullOrWhiteSpace(responsePath)) + else { - continue; + ErrorBanner.Visibility = Visibility.Collapsed; } - - yield return new - { - path = responsePath, - mode = type == "tree" ? "040000" : "100644", - type, - sha = type == "tree" ? $"preview-tree-{path.GetHashCode():x}" : $"preview-blob-{path.GetHashCode():x}", - size = type == "blob" ? Encoding.UTF8.GetByteCount(GetPublicPreviewFileContent(path)) : (int?)null, - url = type == "tree" - ? $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/trees/preview-tree" - : $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/blobs/preview-blob" - }; - } - } - - private static string GetTreePath(string[] pathSegments) - { - if (pathSegments.Length < 6) - { - return string.Empty; - } - - string treeSpecifier = string.Join('/', pathSegments[5..]); - int separatorIndex = treeSpecifier.IndexOf(':'); - return separatorIndex >= 0 && separatorIndex < treeSpecifier.Length - 1 - ? treeSpecifier[(separatorIndex + 1)..].Trim('/') - : string.Empty; - } - - private static bool IsRecursiveTreeRequest(Uri uri) => - uri.Query.Contains("recursive=true", StringComparison.OrdinalIgnoreCase) || - uri.Query.Contains("recursive=1", StringComparison.OrdinalIgnoreCase); - - private static string SerializePublicPreviewContents(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return JsonSerializer.Serialize(PublicPreviewDirectories - .Where(directory => !directory.Contains('/')) - .Select(CreateDirectoryContentItem) - .Concat(PublicPreviewFiles.Keys - .Where(filePath => !filePath.Contains('/')) - .Select(CreateFileContentItem))); - } - - if (PublicPreviewFiles.ContainsKey(path)) - { - return SerializePublicPreviewFile(path); - } - - string prefix = path.TrimEnd('/') + "/"; - var children = PublicPreviewDirectories - .Where(directory => directory.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - .Select(directory => directory[prefix.Length..]) - .Where(rest => rest.Length > 0 && !rest.Contains('/')) - .Select(child => CreateDirectoryContentItem(prefix + child)) - .Concat(PublicPreviewFiles.Keys - .Where(filePath => filePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - .Select(filePath => filePath[prefix.Length..]) - .Where(rest => rest.Length > 0 && !rest.Contains('/')) - .Select(child => CreateFileContentItem(prefix + child))) - .ToList(); - - return JsonSerializer.Serialize(children); - } - - private static object CreateDirectoryContentItem(string path) - { - string name = path.Split('/').Last(); - return new - { - type = "dir", - name, - path, - sha = $"preview-tree-{path.GetHashCode():x}", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/contents/{Uri.EscapeDataString(path)}?ref={PublicPreviewBranch}", - html_url = $"https://github.com/{PublicPreviewOwner}/{PublicPreviewRepository}/tree/{PublicPreviewBranch}/{path}", - git_url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/trees/preview-tree" - }; - } - - private static object CreateFileContentItem(string path) - { - string name = path.Split('/').Last(); - return new - { - type = "file", - name, - path, - sha = $"preview-blob-{path.GetHashCode():x}", - size = Encoding.UTF8.GetByteCount(GetPublicPreviewFileContent(path)), - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/contents/{Uri.EscapeDataString(path)}?ref={PublicPreviewBranch}", - html_url = $"https://github.com/{PublicPreviewOwner}/{PublicPreviewRepository}/blob/{PublicPreviewBranch}/{path}", - git_url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/blobs/preview-blob", - download_url = $"https://raw.githubusercontent.com/{PublicPreviewOwner}/{PublicPreviewRepository}/{PublicPreviewBranch}/{path}" - }; - } - - private static string SerializePublicPreviewFile(string path) - { - string content = GetPublicPreviewFileContent(path); - string encodedContent = Convert.ToBase64String(Encoding.UTF8.GetBytes(content)); - string name = path.Split('/').Last(); - return JsonSerializer.Serialize(new - { - type = "file", - encoding = "base64", - size = Encoding.UTF8.GetByteCount(content), - name, - path, - content = encodedContent, - sha = $"preview-blob-{path.GetHashCode():x}", - url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/contents/{Uri.EscapeDataString(path)}?ref={PublicPreviewBranch}", - html_url = $"https://github.com/{PublicPreviewOwner}/{PublicPreviewRepository}/blob/{PublicPreviewBranch}/{path}", - git_url = $"https://api.github.com/repos/{PublicPreviewOwner}/{PublicPreviewRepository}/git/blobs/preview-blob", - download_url = $"https://raw.githubusercontent.com/{PublicPreviewOwner}/{PublicPreviewRepository}/{PublicPreviewBranch}/{path}" }); } + } - private static string GetPublicPreviewFileContent(string path) - { - return PublicPreviewFiles.TryGetValue(path.TrimStart('/'), out string? content) - ? content - : "namespace JitHub.Preview;\n\npublic static class Placeholder\n{\n public const string Message = \"Preview file loaded.\";\n}\n"; - } - - private static string GetContentType(string filePath) - { - if (filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) - { - return "text/markdown"; - } - - if (filePath.EndsWith(".css", StringComparison.OrdinalIgnoreCase)) - { - return "text/css"; - } - - if (filePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) - { - return "application/json"; - } - - return "text/plain"; - } - - private static string GetRequestHeader(CoreWebView2HttpRequestHeaders headers, string name) - { - try - { - return headers.GetHeader(name); - } - catch (ArgumentException) - { - return string.Empty; - } - catch (COMException ex) when (ex.HResult == HeaderNotFoundHResult) - { - return string.Empty; - } - } - - private static string? GetTokenQueryParameter(Uri uri) - { - if (string.IsNullOrWhiteSpace(uri.Query)) - { - return null; - } - - string query = uri.Query.TrimStart('?'); - foreach (string queryPart in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) - { - int separatorIndex = queryPart.IndexOf('='); - string key = separatorIndex >= 0 ? queryPart[..separatorIndex] : queryPart; - if (!string.Equals(key, "token", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - return separatorIndex >= 0 ? Uri.UnescapeDataString(queryPart[(separatorIndex + 1)..]) : string.Empty; - } - - return null; - } - + private void ShowError(string message) + { + ErrorMessageText.Text = message; + ErrorBanner.Visibility = Visibility.Visible; } } - - - - diff --git a/download-vsocde.ps1 b/download-vsocde.ps1 deleted file mode 100644 index 798698d..0000000 --- a/download-vsocde.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -# Get the current script path -$scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition - -# Set the GitHub repository name -$repoName = "nerocui/jithub-vs-code" - -# Set the asset pattern for the zip file -$assetPattern = "*-vs-code.zip" - -# Set the destination path for the zip file -$zipPath = Join-Path -Path $scriptPath -ChildPath "jithub-vs-code.zip" - -# Set the destination path for the extracted folder -$folderPath = Join-Path -Path $scriptPath -ChildPath "JitHub\Assets" - -# Call the GitHub API to get the latest release information -$releasesUri = "https://api.github.com/repos/$repoName/releases/latest" -$release = Invoke-RestMethod -Uri $releasesUri - -# Find the download URL for the zip file that matches the asset pattern -$downloadUrl = $release.assets | Where-Object { $_.name -like $assetPattern } | Select-Object -ExpandProperty browser_download_url - -# Download the zip file using Invoke-WebRequest -Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath - -# Extract the zip file using Expand-Archive -Expand-Archive -Path $zipPath -DestinationPath $folderPath - -# Remove the zip file using Remove-Item -Remove-Item -Path $zipPath diff --git a/eng/Sync-JitHubVsCodeAssets.ps1 b/eng/Sync-JitHubVsCodeAssets.ps1 deleted file mode 100644 index 0abf58d..0000000 --- a/eng/Sync-JitHubVsCodeAssets.ps1 +++ /dev/null @@ -1,122 +0,0 @@ -[CmdletBinding()] -param( - [string]$VsCodeRepoPath, - [string]$DestinationPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts\EditorAssets\dist'), - [switch]$SkipInstall -) - -$ErrorActionPreference = 'Stop' - -function Resolve-RepoRoot { - return Split-Path -Parent $PSScriptRoot -} - -function Resolve-VsCodeRepoPath { - param( - [string]$ExplicitPath - ) - - $repoRoot = Resolve-RepoRoot - $candidates = @( - $ExplicitPath, - $env:JITHUB_VSCODE_PATH, - (Join-Path $repoRoot '..\jithub-vs-code'), - 'E:\jithub-vs-code' - ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - - foreach ($candidate in $candidates) { - $fullPath = [System.IO.Path]::GetFullPath($candidate) - if ( - (Test-Path -LiteralPath $fullPath) -and - (Test-Path -LiteralPath (Join-Path $fullPath 'package.json')) -and - (Test-Path -LiteralPath (Join-Path $fullPath 'webpack.config.js')) - ) { - return $fullPath - } - } - - throw 'Unable to locate the jithub-vs-code repository. Pass -VsCodeRepoPath or set JITHUB_VSCODE_PATH.' -} - -function Invoke-CheckedCommand { - param( - [Parameter(Mandatory = $true)] - [string]$FilePath, - - [string[]]$ArgumentList = @(), - - [Parameter(Mandatory = $true)] - [string]$WorkingDirectory - ) - - Write-Host "> $FilePath $($ArgumentList -join ' ')" - Push-Location $WorkingDirectory - try { - & $FilePath @ArgumentList - if ($LASTEXITCODE -ne 0) { - throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" - } - } - finally { - Pop-Location - } -} - -function Clear-DestinationDirectory { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - $attempt = 0 - while ($attempt -lt 3) { - $attempt++ - try { - Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction Stop - return - } - catch { - if ($attempt -ge 3) { - throw "Unable to clear $Path. Close JitHub or any process using files under Assets\\dist, then try again. $($_.Exception.Message)" - } - - Start-Sleep -Seconds 1 - } - } -} - -$repoPath = Resolve-VsCodeRepoPath -ExplicitPath $VsCodeRepoPath -$destinationFullPath = [System.IO.Path]::GetFullPath($DestinationPath) - -if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - throw 'Node.js is required to build jithub-vs-code assets.' -} - -if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) { - throw 'Yarn is required to build jithub-vs-code assets.' -} - -Write-Host "Using jithub-vs-code repository at: $repoPath" - -if (-not $SkipInstall) { - Invoke-CheckedCommand -FilePath 'yarn' -ArgumentList @('--frozen-lockfile') -WorkingDirectory $repoPath -} - -Invoke-CheckedCommand -FilePath 'yarn' -ArgumentList @('build') -WorkingDirectory $repoPath - -$sourceDistPath = Join-Path $repoPath 'dist' -if ( - -not (Test-Path -LiteralPath $sourceDistPath) -or - -not (Test-Path -LiteralPath (Join-Path $sourceDistPath 'index.html')) -) { - throw "The jithub-vs-code build did not produce the expected dist output at $sourceDistPath" -} - -New-Item -ItemType Directory -Path $destinationFullPath -Force | Out-Null - -Clear-DestinationDirectory -Path $destinationFullPath - -Copy-Item -Path (Join-Path $sourceDistPath '*') -Destination $destinationFullPath -Recurse -Force - -Write-Host "Synced editor assets to: $destinationFullPath" diff --git a/sync-vscode-assets.ps1 b/sync-vscode-assets.ps1 deleted file mode 100644 index 4e61421..0000000 --- a/sync-vscode-assets.ps1 +++ /dev/null @@ -1,11 +0,0 @@ -[CmdletBinding()] -param( - [string]$VsCodeRepoPath, - [switch]$SkipInstall -) - -$ErrorActionPreference = 'Stop' - -& (Join-Path $PSScriptRoot 'eng\Sync-JitHubVsCodeAssets.ps1') ` - -VsCodeRepoPath $VsCodeRepoPath ` - -SkipInstall:$SkipInstall From af797aebd6d9fd67031b801b9ef0cf64bc4dae17 Mon Sep 17 00:00:00 2001 From: Melody Song Date: Thu, 7 May 2026 19:54:23 -0700 Subject: [PATCH 02/21] fix: wrap tree ItemTemplate in ContentControl to resolve TreeViewNode DataContext cast In TreeViewNode mode the outer DataTemplate's DataContext is TreeViewNode, not the VM. Wrapping with a ContentControl and binding Content={Binding Content} routes the typed inner DataTemplate to RepoTreeNodeViewModel, fixing the WinRT.IInspectable cast exception at runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Controls/CodeViewer/RepoFileTreeView.xaml | 69 +++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml index b4890ea..e47f6cb 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml +++ b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml @@ -54,39 +54,50 @@ ItemInvoked="OnItemInvoked" SelectionMode="Single"> - - - - - - + + + + + + + + + + - - + + - - + + - - + + - - + + + + + From 325c4d56f98b46e4da7512696799ff00eab69be8 Mon Sep 17 00:00:00 2001 From: Melody Song Date: Thu, 7 May 2026 22:08:59 -0700 Subject: [PATCH 03/21] feat(code-viewer): broad language support via direct Lexilla CreateLexer Replace broken SCI_SETLEXERLANGUAGE (4006) path with P/Invoke into WinUIEditor.dll's exported Lexilla CreateLexer + Editor.SetILexer (4033). The deprecated message id was a no-op, which is why PowerShell, Bash, Batch, SQL, etc. never highlighted. Vastly expand ScintillaLexerDatabase: add asm, tcl, latex, vim, pascal/delphi, erlang, lisp/clojure/scheme, julia, nim, crystal, solidity, graphql, protobuf, hcl/terraform, ada, fortran, ocaml, matlab/octave, smalltalk, verilog/systemverilog, vhdl, nginx, actionscript, groovy/gradle, razor/cshtml, jsx/tsx with React-aware keywords, plus a richer JS keyword set. Expand language-map.json: filenames NuGet.Config / nuget.config / App.config / Web.config / packages.config / Directory.Build.props / global.json / appsettings*.json / Jenkinsfile / WORKSPACE / BUILD.bazel / project.clj / mix.exs etc. New extensions .slnx, .axaml, .runsettings, .resw, .razor, .cshtml, .pas, .f90, .ada, .sol, .tfvars, .bicep, .pug, .liquid, .astro, .coffee, .feature and many more. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assets/CodeViewer/language-map.json | 148 ++++- .../CodeViewer/CodeEditorControl.xaml.cs | 98 +-- .../CodeViewer/ScintillaLexerDatabase.cs | 572 +++++++++++++++++- 3 files changed, 748 insertions(+), 70 deletions(-) diff --git a/JitHub.WinUI/Assets/CodeViewer/language-map.json b/JitHub.WinUI/Assets/CodeViewer/language-map.json index 704df7b..d3b656a 100644 --- a/JitHub.WinUI/Assets/CodeViewer/language-map.json +++ b/JitHub.WinUI/Assets/CodeViewer/language-map.json @@ -154,11 +154,99 @@ ".xaml": "xml", ".svg": "xml", ".wsdl": "xml", - ".htm": "html" + ".htm": "html", + ".slnx": "xml", + ".sln": "ini", + ".axaml": "xml", + ".manifest": "xml", + ".rdlc": "xml", + ".config": "xml", + ".runsettings": "xml", + ".resw": "xml", + ".cake": "csharp", + ".razor": "razor", + ".cshtml": "razor", + ".vbhtml": "razor", + ".aspx": "html", + ".ascx": "html", + ".master": "html", + ".dtd": "xml", + ".pp": "pascal", + ".pas": "pascal", + ".dpr": "pascal", + ".dfm": "pascal", + ".f": "fortran", + ".f90": "fortran", + ".f95": "fortran", + ".f03": "fortran", + ".f08": "fortran", + ".for": "fortran", + ".ftn": "fortran", + ".ada": "ada", + ".adb": "ada", + ".ads": "ada", + ".cob": "cobol", + ".cbl": "cobol", + ".cpy": "cobol", + ".st": "smalltalk", + ".cls.st": "smalltalk", + ".sol": "solidity", + ".tfvars": "hcl", + ".bicep": "hcl", + ".pug": "html", + ".jade": "html", + ".haml": "html", + ".slim": "html", + ".liquid": "html", + ".njk": "html", + ".twig": "html", + ".astro": "html", + ".hbs": "html", + ".mustache": "html", + ".erb": "html", + ".tt": "csharp", + ".ttinclude": "csharp", + ".cake": "csharp", + ".cs.template": "csharp", + ".cu": "cpp", + ".cuh": "cpp", + ".hlsl": "cpp", + ".glsl": "cpp", + ".frag": "cpp", + ".vert": "cpp", + ".comp": "cpp", + ".geom": "cpp", + ".tesc": "cpp", + ".tese": "cpp", + ".d": "cpp", + ".d.ts": "typescript", + ".as": "actionscript", + ".mxml": "xml", + ".coffee": "javascript", + ".litcoffee": "javascript", + ".lock": "yaml", + ".gitkeep": "text", + ".diff": "diff", + ".patch": "diff", + ".bzl": "cmake", + ".starlark": "cmake", + ".thrift": "protobuf", + ".rkt": "scheme", + ".rktl": "scheme", + ".sps": "scheme", + ".lsp": "lisp", + ".asd": "lisp", + ".applescript": "text", + ".scpt": "text", + ".au3": "text", + ".feature": "text" }, "filenames": { "Dockerfile": "dockerfile", + "dockerfile": "dockerfile", + "Containerfile": "dockerfile", "Makefile": "makefile", + "makefile": "makefile", "GNUmakefile": "makefile", "CMakeLists.txt": "cmake", "Gemfile": "ruby", @@ -169,6 +257,7 @@ ".gitignore": "ini", ".gitattributes": "ini", ".gitmodules": "ini", + ".gitconfig": "ini", ".editorconfig": "ini", ".npmrc": "ini", ".yarnrc": "ini", @@ -180,7 +269,8 @@ ".lintstagedrc": "json", "package.json": "json", "package-lock.json": "json", - "yarn.lock": "text", + "yarn.lock": "yaml", + "pnpm-lock.yaml": "yaml", "tsconfig.json": "json", "jsconfig.json": "json", ".nvmrc": "text", @@ -195,13 +285,15 @@ "go.mod": "go", "go.sum": "text", "build.gradle": "groovy", + "build.gradle.kts": "kotlin", "settings.gradle": "groovy", + "settings.gradle.kts": "kotlin", "pom.xml": "xml", "build.xml": "xml", "composer.json": "json", "composer.lock": "json", ".htaccess": "ini", - "nginx.conf": "ini", + "nginx.conf": "nginx", "Vagrantfile": "ruby", "Procfile": "text", "Brewfile": "ruby", @@ -221,7 +313,55 @@ "docker-compose.yaml": "yaml", ".travis.yml": "yaml", "appveyor.yml": "yaml", - ".circleci": "yaml" + ".circleci": "yaml", + + "NuGet.Config": "xml", + "nuget.config": "xml", + "NuGet.config": "xml", + "App.config": "xml", + "Web.config": "xml", + "App.Config": "xml", + "Web.Config": "xml", + "packages.config": "xml", + "App.manifest": "xml", + "App.Manifest": "xml", + "global.json": "json", + "Directory.Build.props": "xml", + "Directory.Build.targets": "xml", + "Directory.Packages.props": "xml", + "nuget.exe.config": "xml", + "AssemblyInfo.cs": "csharp", + "GlobalSuppressions.cs": "csharp", + "appsettings.json": "json", + "appsettings.Development.json": "json", + "appsettings.Production.json": "json", + "launchSettings.json": "json", + + "BUILD": "cmake", + "BUILD.bazel": "cmake", + "WORKSPACE": "cmake", + "WORKSPACE.bazel": "cmake", + "MODULE.bazel": "cmake", + + "Jenkinsfile": "groovy", + + ".env.local": "ini", + ".env.development": "ini", + ".env.production": "ini", + ".env.test": "ini", + ".vimrc": "vim", + "_vimrc": "vim", + ".tmux.conf": "ini", + "Procfile.dev": "text", + "shadow-cljs.edn": "clojure", + "deps.edn": "clojure", + "project.clj": "clojure", + ".clojure-lsp": "clojure", + "rebar.config": "erlang", + "stack.yaml": "yaml", + "cabal.project": "haskell", + "mix.exs": "elixir", + "mix.lock": "elixir" }, "interpreters": { "python": "python", diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs index 7219d43..14afbba 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs +++ b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml.cs @@ -236,32 +236,40 @@ private void ApplyLanguageId() ScintillaLexerDatabase.LexillaConfig? lexilla = ScintillaLexerDatabase.GetLexillaConfig(langId); bool isDark = ActualTheme == ElementTheme.Dark; - if (winuiId != null) + // Strategy: + // 1. If language has a Lexilla config that uses a non-cpp/native lexer, + // load the lexer directly via CreateLexer + SetILexer for accurate + // tokenization (PowerShell, Bash, SQL, Python, etc.) + // 2. If language maps to one of WinUIEdit's 8 native languages + // (cpp, csharp, javascript, json, html, xml, yaml, plaintext), + // use HighlightingLanguage for VS Code-equivalent built-in colors, + // then optionally override keywords for cpp-mapped languages + // (Java, Go, Rust, TypeScript, etc.). + + if (lexilla != null && winuiId == null) + { + // Lexilla-direct path + InnerEditor.HighlightingLanguage = "plaintext"; + if (TryLoadLexilla(lexilla.LexerName)) + { + if (lexilla.Keywords0 != null) InnerEditor.Editor.SetKeyWords(0, lexilla.Keywords0); + if (lexilla.Keywords1 != null) InnerEditor.Editor.SetKeyWords(1, lexilla.Keywords1); + if (lexilla.Keywords2 != null) InnerEditor.Editor.SetKeyWords(2, lexilla.Keywords2); + if (lexilla.StyleMap != null) ApplyLexillaTokenColors(lexilla, isDark); + } + } + else if (winuiId != null) { // WinUIEdit native: sets up lexer + VS Code token colors internally. InnerEditor.HighlightingLanguage = winuiId; - // Apply custom keywords for languages mapped to "cpp" (Java, Go, Rust, etc.) + // Apply custom keywords for cpp-mapped languages (Java, Go, Rust, TypeScript, etc.). var kw = ScintillaLexerDatabase.GetKeywordOverride(langId); if (kw != null) { - if (kw.Value.Keywords0 != null) SendKeywords(0, kw.Value.Keywords0); - if (kw.Value.Keywords1 != null) SendKeywords(1, kw.Value.Keywords1); + if (kw.Value.Keywords0 != null) InnerEditor.Editor.SetKeyWords(0, kw.Value.Keywords0); + if (kw.Value.Keywords1 != null) InnerEditor.Editor.SetKeyWords(1, kw.Value.Keywords1); } - - // Apply extra token colors for cpp-mapped Lexilla configs where relevant - if (lexilla?.StyleMap != null) - ApplyLexillaTokenColors(lexilla, isDark); - } - else if (lexilla != null) - { - // Lexilla-direct: reset to plaintext first (clears any prior lexer), - // then switch to the Lexilla lexer and apply our own token colors. - InnerEditor.HighlightingLanguage = "plaintext"; - SendLexerLanguage(lexilla.LexerName); - if (lexilla.Keywords0 != null) SendKeywords(0, lexilla.Keywords0); - if (lexilla.Keywords1 != null) SendKeywords(1, lexilla.Keywords1); - if (lexilla.StyleMap != null) ApplyLexillaTokenColors(lexilla, isDark); } else { @@ -374,38 +382,44 @@ private void ApplyThemeColors() } // ────────────────────────────────────────────────────────────────── - // Lexilla via SendMessage + // Lexilla integration via P/Invoke into WinUIEditor.dll + // + // WinUIEditor.dll exports the Lexilla C API (CreateLexer, GetLexerCount, + // GetLexerName, etc.) since Lexilla is statically linked into it. + // We P/Invoke CreateLexer to obtain an ILexer5* pointer and pass it to + // Editor.SetILexer (Scintilla message 4033). This is the same path + // WinUIEdit's C++ code uses internally for its 8 built-in languages, + // unlocking the full ~120 Lexilla lexers for our app. + // + // The deprecated SCI_SETLEXERLANGUAGE (4006) is NOT supported by modern + // Scintilla and was a no-op — that bug is what caused PowerShell, Bash, + // Batch, etc. to render as plain text in earlier iterations. // ────────────────────────────────────────────────────────────────── - private void SendLexerLanguage(string lexerName) - { - IntPtr ptr = Marshal.StringToHGlobalAnsi(lexerName); - try - { - InnerEditor.SendMessage( - (WinUIEditor.ScintillaMessage)ScintillaLexerDatabase.SciSetLexerLanguage, - 0, - ptr.ToInt64()); - } - finally - { - Marshal.FreeHGlobal(ptr); - } - } + [DllImport("WinUIEditor.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)] + private static extern IntPtr CreateLexer([MarshalAs(UnmanagedType.LPStr)] string name); - private void SendKeywords(int set, string keywords) + private bool TryLoadLexilla(string lexerName) { - IntPtr ptr = Marshal.StringToHGlobalAnsi(keywords); try { - InnerEditor.SendMessage( - (WinUIEditor.ScintillaMessage)ScintillaLexerDatabase.SciSetKeyWords, - (ulong)set, - ptr.ToInt64()); + IntPtr ptr = CreateLexer(lexerName); + if (ptr == IntPtr.Zero) + { + Debug.WriteLine($"[CodeEditorControl] Lexilla CreateLexer('{lexerName}') returned null."); + return false; + } + + InnerEditor.Editor.ClearDocumentStyle(); + // Editor.SetILexer corresponds to SCI_SETILEXER (4033) — wParam ignored, + // lParam is an ILexer5* cast to UInt64. + InnerEditor.Editor.SetILexer((ulong)ptr.ToInt64()); + return true; } - finally + catch (Exception ex) { - Marshal.FreeHGlobal(ptr); + Debug.WriteLine($"[CodeEditorControl] TryLoadLexilla('{lexerName}') failed: {ex.Message}"); + return false; } } diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs b/JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs index cb738bc..1b0b305 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs +++ b/JitHub.WinUI/Views/Controls/CodeViewer/ScintillaLexerDatabase.cs @@ -100,7 +100,10 @@ public sealed record LexillaConfig( string LexerName, string? Keywords0, string? Keywords1, - (int StyleId, TokenKind Kind)[]? StyleMap); + (int StyleId, TokenKind Kind)[]? StyleMap) + { + public string? Keywords2 { get; init; } + } // ── Tier 1: map our IDs to WinUIEdit HighlightingLanguage strings ────────── // WinUIEdit supports: cpp, csharp, javascript, json, html, xml, yaml, plaintext @@ -143,9 +146,8 @@ public sealed record LexillaConfig( ["odin"] = "cpp", ["carbon"] = "cpp", - // JavaScript-like - ["jsx"] = "javascript", - ["tsx"] = "javascript", + // JavaScript-like — handled via Lexilla cpp lexer with rich keywords + // (jsx/tsx removed from WinUIEditMap so they fall through to LexillaMap) ["vue"] = "html", ["svelte"] = "html", ["handlebars"] = "html", @@ -704,6 +706,458 @@ public sealed record LexillaConfig( (9, TokenKind.Keyword2), // SCE_CMAKE_COMMANDS_DEPRECATED (12, TokenKind.Variable), // SCE_CMAKE_VARIABLE2 }), + + // ═════════════════════════════════════════════════════════════════ + // Additional broad language coverage (top GitHub languages) + // ═════════════════════════════════════════════════════════════════ + + // ── JavaScript: WinUIEdit uses cpp lexer; this entry feeds rich keywords via override ── + ["javascript"] = new("cpp", + Keywords0: "abstract arguments async await boolean break byte case catch char class const " + + "continue debugger default delete do double else enum eval export extends " + + "false final finally float for from function get goto if implements import in " + + "instanceof int interface let long native new null of package private protected " + + "public return set short static super switch synchronized this throw throws " + + "transient true try typeof undefined var void volatile while with yield", + Keywords1: "Array ArrayBuffer Boolean Date Error Function Infinity JSON Map Math NaN Number " + + "Object Promise Proxy Reflect RegExp Set String Symbol WeakMap WeakSet console " + + "document window globalThis Intl URL URLSearchParams fetch require module exports", + StyleMap: null), + + // ── JSX/TSX ────────────────────────────────────────────────────── + ["jsx"] = new("cpp", + Keywords0: "as async await break case catch class const continue debugger default delete " + + "do else enum export extends false finally for from function get if import " + + "in instanceof let new null of package private protected public return set " + + "static super switch this throw true try typeof undefined var void while with yield", + Keywords1: "React Component Fragment useState useEffect useContext useReducer useCallback " + + "useMemo useRef useImperativeHandle useLayoutEffect useDebugValue useId " + + "useTransition useDeferredValue Array Object String Number Boolean Promise Map Set", + StyleMap: null), + ["tsx"] = new("cpp", + Keywords0: "abstract any as asserts async await bigint boolean break case catch class const " + + "constructor continue debugger declare default delete do else enum export extends " + + "false finally for from function get if implements import in infer instanceof " + + "interface is keyof let module namespace never new null number object of override " + + "package private protected public readonly require return satisfies set static " + + "string super switch symbol this throw true try type typeof undefined unique " + + "unknown var void while with yield", + Keywords1: "React Component Fragment useState useEffect useContext useReducer useCallback " + + "useMemo useRef Array Object String Number Boolean Promise Map Set Record Partial " + + "Required Readonly Pick Omit Exclude Extract NonNullable Parameters ReturnType", + StyleMap: null), + + // ── HTML / XML keyword override (handled by WinUIEdit but we keep here for reference) ── + + // ── ASM / x86 / MASM / GAS / NASM ───────────────────────────────── + ["asm"] = new("asm", + Keywords0: "aaa aad aam aas adc add and call cbw clc cld cli cmc cmp cmpsb cmpsw cwd " + + "daa das dec div esc hlt idiv imul in inc int into iret ja jae jb jbe jc " + + "jcxz je jg jge jl jle jmp jna jnae jnb jnbe jnc jne jng jnge jnl jnle jno " + + "jnp jns jnz jo jp jpe jpo js jz lahf lds lea les lock lodsb lodsw loop " + + "loope loopne loopnz loopz mov movsb movsw mul neg nop not or out pop popf " + + "push pushf rcl rcr ret retf retn rol ror sahf sal sar sbb scasb scasw shl " + + "shr stc std sti stosb stosw sub test wait xchg xlat xor mov add sub mul div " + + "movzx movsx lea cdq cqo syscall ret leave int3", + Keywords1: "ah al ax bh bl bp bx ch cl cs cx dh di dl ds dx es ip si sp ss " + + "eax ebx ecx edx ebp esp esi edi rax rbx rcx rdx rbp rsp rsi rdi r8 r9 " + + "r10 r11 r12 r13 r14 r15 xmm0 xmm1 xmm2 xmm3 xmm4 xmm5 xmm6 xmm7 ymm0 zmm0", + StyleMap: new[] + { + (1, TokenKind.Comment), + (2, TokenKind.Number), + (3, TokenKind.String), + (4, TokenKind.Operator), + (5, TokenKind.Variable), // SCE_ASM_IDENTIFIER + (6, TokenKind.Keyword), // SCE_ASM_CPUINSTRUCTION + (7, TokenKind.Keyword2), // SCE_ASM_MATHINSTRUCTION + (8, TokenKind.Type), // SCE_ASM_REGISTER + (9, TokenKind.Preprocessor), // SCE_ASM_DIRECTIVE + (10, TokenKind.Preprocessor),// SCE_ASM_DIRECTIVEOPERAND + (11, TokenKind.Comment), // SCE_ASM_COMMENTBLOCK + (12, TokenKind.String), // SCE_ASM_CHARACTER + (13, TokenKind.String), // SCE_ASM_STRINGEOL + (14, TokenKind.Type), // SCE_ASM_EXTINSTRUCTION + }) + { + Keywords2 = "section text data bss global extern db dw dd dq resb resw resd resq " + + "equ times byte word dword qword ptr offset" + }, + + // ── Tcl ─────────────────────────────────────────────────────────── + ["tcl"] = new("tcl", + Keywords0: "after append apply array auto_execok auto_import auto_load auto_mkindex " + + "auto_qualify auto_reset bgerror binary break case catch cd chan clock close " + + "concat continue coroutine dde dict encoding eof error eval exec exit expr " + + "fblocked fconfigure fcopy file fileevent flush for foreach format gets glob " + + "global history if incr info interp join lappend lassign lindex linsert list " + + "llength lmap load lpop lrange lremove lrepeat lreplace lreverse lsearch lset " + + "lsort namespace next nextto open package parray pid proc puts pwd read regexp " + + "regsub rename return scan seek set socket source split string subst switch " + + "tailcall tcl_endOfWord tell throw time timerate trace try unknown unload " + + "unset update uplevel upvar variable vwait while yield yieldto zlib", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), + (2, TokenKind.Comment), + (3, TokenKind.Comment), + (4, TokenKind.Number), + (5, TokenKind.Keyword), + (6, TokenKind.String), + (7, TokenKind.String), + (8, TokenKind.String), + (9, TokenKind.Operator), + (10, TokenKind.Variable), + (11, TokenKind.Variable), + }), + + // ── LaTeX ───────────────────────────────────────────────────────── + ["latex"] = new("latex", + Keywords0: null, + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Keyword), // SCE_L_COMMAND + (2, TokenKind.Type), // SCE_L_TAG + (3, TokenKind.Operator), // SCE_L_MATH + (4, TokenKind.Comment), // SCE_L_COMMENT + (5, TokenKind.Type), // SCE_L_TAG2 + (6, TokenKind.String), // SCE_L_MATH2 + (7, TokenKind.Comment), // SCE_L_COMMENT2 + (8, TokenKind.String), // SCE_L_VERBATIM + (9, TokenKind.Number), // SCE_L_SHORTCMD + (10, TokenKind.Operator), // SCE_L_SPECIAL + (11, TokenKind.Code), // SCE_L_CMDOPT + (12, TokenKind.String), // SCE_L_ERROR + }), + + // ── Vim script ──────────────────────────────────────────────────── + ["vim"] = new("vim", + Keywords0: "if elseif else endif while endwhile for endfor function endfunction return " + + "let unlet const final lockvar unlockvar try catch finally endtry throw " + + "set setlocal setglobal map nmap vmap imap omap xmap smap cmap tmap " + + "noremap nnoremap vnoremap inoremap onoremap xnoremap snoremap cnoremap " + + "tnoremap autocmd augroup syntax highlight command call execute echo echom " + + "echoerr echohl source runtime packadd silent verbose redir delfunction " + + "abort range dict closure", + Keywords1: null, + StyleMap: null), + + // ── Pascal / Delphi ────────────────────────────────────────────── + ["pascal"] = new("pascal", + Keywords0: "absolute abstract and array as asm assembler at automated begin case cdecl " + + "class const constructor contains default deprecated destructor dispid " + + "dispinterface div do downto dynamic else end except export exports external " + + "far file final finalization finally for forward function generic goto if " + + "implementation implements in index inherited initialization inline interface " + + "is label library local message mod name near nil nodefault not object of on " + + "operator or out overload override package packed pascal platform private " + + "procedure program property protected public published raise read readonly " + + "record register reintroduce repeat requires resourcestring safecall sealed " + + "set shl shr static stdcall stored strict string then threadvar to try type " + + "unit unsafe until uses var varargs virtual while with write writeonly xor", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), + (2, TokenKind.Comment), + (3, TokenKind.Comment), + (4, TokenKind.Preprocessor), + (5, TokenKind.Number), + (6, TokenKind.Number), + (7, TokenKind.Keyword), + (8, TokenKind.String), + (9, TokenKind.String), + (10, TokenKind.Operator), + (11, TokenKind.Variable), + (13, TokenKind.String), + }), + + // ── Erlang ─────────────────────────────────────────────────────── + ["erlang"] = new("erlang", + Keywords0: "after and andalso band begin bnot bor bsl bsr bxor case catch cond div end " + + "fun if let not of or orelse query receive rem try when xor", + Keywords1: "atom binary boolean byte char float function integer iodata iolist list map " + + "maybe_improper_list mfa module no_return non_neg_integer none nonempty_list " + + "number pid port pos_integer reference string term timeout tuple", + StyleMap: null), + + // ── Lisp / Clojure / Scheme / Racket ───────────────────────────── + ["lisp"] = new("lisp", + Keywords0: "and append apply assoc atom car case cdr cond cons defmacro defun defvar " + + "defparameter defconstant defstruct defclass defmethod defgeneric do dolist " + + "dotimes eq eql equal error eval flet funcall function if labels lambda let " + + "let* list loop macroexpand mapcar member multiple-value-bind nil not nth " + + "or otherwise package position progn quote return setf setq t the typecase " + + "unless unwind-protect values when", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), + (2, TokenKind.Comment), + (3, TokenKind.Number), + (4, TokenKind.Keyword), + (5, TokenKind.Keyword2), + (6, TokenKind.String), + (7, TokenKind.String), + (10, TokenKind.Operator), + (11, TokenKind.Variable), + }), + ["clojure"] = new("lisp", + Keywords0: "def defn defmacro defmulti defmethod defprotocol defrecord deftype let " + + "letfn fn if when when-not when-let if-let if-not cond condp case do " + + "doseq dotimes for loop recur try catch finally throw quote ns require " + + "import use refer in-ns and or not nil true false", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), + (2, TokenKind.Comment), + (3, TokenKind.Number), + (4, TokenKind.Keyword), + (5, TokenKind.Keyword2), + (6, TokenKind.String), + (10, TokenKind.Operator), + }), + ["scheme"] = new("lisp", + Keywords0: "and begin case cond define define-syntax delay do else if lambda let let* " + + "letrec or quasiquote quote set! syntax-rules unquote unquote-splicing when " + + "unless => library export import", + Keywords1: null, + StyleMap: new[] + { + (1, TokenKind.Comment), + (2, TokenKind.Comment), + (3, TokenKind.Number), + (4, TokenKind.Keyword), + (6, TokenKind.String), + (10, TokenKind.Operator), + }), + + // ── Julia (use cpp lexer for tokenisation, override keywords) ──── + ["julia"] = new("cpp", + Keywords0: "abstract baremodule begin break catch ccall const continue do else elseif " + + "end export false finally for function global if import in isa let local " + + "macro module mutable primitive quote return struct true try type typealias " + + "using where while", + Keywords1: "Int Int8 Int16 Int32 Int64 Int128 UInt UInt8 UInt16 UInt32 UInt64 UInt128 " + + "Float16 Float32 Float64 Bool Char String Symbol Array Vector Matrix Tuple " + + "NamedTuple Dict Set Nothing Missing Number Real Integer Signed Unsigned Any", + StyleMap: null), + + // ── Nim ─────────────────────────────────────────────────────────── + ["nim"] = new("cpp", + Keywords0: "addr and as asm bind block break case cast concept const continue converter " + + "defer discard distinct div do elif else end enum except export finally for " + + "from func generic if import in include interface is isnot iterator let " + + "macro method mixin mod nil not notin object of or out proc ptr raise ref " + + "return shl shr static template try tuple type using var when while xor yield", + Keywords1: "int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float float32 " + + "float64 bool char string cstring byte natural Positive seq array", + StyleMap: null), + + // ── Crystal (Ruby-like) ────────────────────────────────────────── + ["crystal"] = new("ruby", + Keywords0: "abstract alias as asm begin break case class def do else elsif end ensure " + + "enum extend false for fun if in include instance_sizeof is_a lib macro " + + "module next nil of out pointerof private protected require rescue return " + + "select self sizeof struct super then true type typeof union unless until " + + "verbatim when while with yield", + Keywords1: null, + StyleMap: null), + + // ── Solidity (cpp-ish) ─────────────────────────────────────────── + ["solidity"] = new("cpp", + Keywords0: "abstract address after alias anonymous apply as assembly assert auto break " + + "calldata case catch constant constructor continue contract copyof default " + + "define delete do else emit enum error event experimental external fallback " + + "false final for function hex if immutable implements import in indexed " + + "inline interface internal is let library macro mapping match memory " + + "modifier modifies new null of override partial payable pragma private " + + "promise public pure receive reference relocatable return returns sealed " + + "sizeof static storage struct super switch this throw true try type typedef " + + "typeof ufixed unchecked using var view virtual while", + Keywords1: "address bool byte bytes bytes1 bytes2 bytes4 bytes8 bytes16 bytes32 fixed " + + "int int8 int16 int32 int64 int128 int256 string uint uint8 uint16 uint32 " + + "uint64 uint128 uint256 wei gwei ether", + StyleMap: null), + + // ── GraphQL ─────────────────────────────────────────────────────── + ["graphql"] = new("cpp", + Keywords0: "type interface union enum schema scalar directive input fragment query " + + "mutation subscription on extend implements true false null", + Keywords1: "Int Float String Boolean ID", + StyleMap: null), + + // ── Protobuf ────────────────────────────────────────────────────── + ["protobuf"] = new("cpp", + Keywords0: "syntax import option package message service rpc stream returns enum " + + "extend extensions oneof reserved repeated optional required map true false", + Keywords1: "double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 " + + "sfixed32 sfixed64 bool string bytes", + StyleMap: null), + + // ── HCL / Terraform ────────────────────────────────────────────── + ["hcl"] = new("cpp", + Keywords0: "resource provider variable data output module locals terraform required_providers " + + "required_version backend provisioner connection lifecycle dynamic count for_each " + + "depends_on if else true false null", + Keywords1: "string number bool list map set object tuple any", + StyleMap: null), + + // ── Ada ─────────────────────────────────────────────────────────── + ["ada"] = new("ada", + Keywords0: "abort abs abstract accept access aliased all and array at begin body case " + + "constant declare delay delta digits do else elsif end entry exception exit " + + "for function generic goto if in interface is limited loop mod new not null " + + "of or others out overriding package pragma private procedure protected " + + "raise range record rem renames requeue return reverse select separate some " + + "subtype synchronized tagged task terminate then type until use when while " + + "with xor", + Keywords1: null, + StyleMap: null), + + // ── Fortran ────────────────────────────────────────────────────── + ["fortran"] = new("fortran", + Keywords0: "allocatable allocate assign assignment associate asynchronous backspace " + + "block blockdata call case class close codimension common contains continue " + + "critical cycle data deallocate deferred dimension do double doubleprecision " + + "elemental else elseif elsewhere end endassociate endblock endblockdata enddo " + + "endenum endfile endforall endfunction endif endinterface endmodule " + + "endprogram endselect endsubmodule endsubroutine endtype endwhere entry enum " + + "enumerator equivalence error exit extends external final flush forall " + + "format function generic go goto if implicit import impure include inquire " + + "intent interface intrinsic kind len lock module namelist non_overridable " + + "none nopass nullify only open operator optional parameter pass pause " + + "pointer print private procedure program protected public pure read recursive " + + "result return rewind rewrite save select sequence stop submodule subroutine " + + "sync target then type unlock use value volatile wait where while write", + Keywords1: "integer real complex logical character double precision", + StyleMap: null), + + // ── OCaml ───────────────────────────────────────────────────────── + ["caml"] = new("caml", + Keywords0: "and as assert asr begin class constraint do done downto else end exception " + + "external false for fun function functor if in include inherit initializer " + + "land lazy let lor lsl lsr lxor match method mod module mutable new nonrec " + + "object of open or private rec sig struct then to true try type val virtual " + + "when while with", + Keywords1: null, + StyleMap: null), + + // ── Octave / Matlab ────────────────────────────────────────────── + ["matlab"] = new("matlab", + Keywords0: "break case catch classdef continue do else elseif end endfor endfunction " + + "endif endparfor endswitch endwhile error for function global if methods " + + "otherwise parfor persistent properties return spmd switch try while", + Keywords1: null, + StyleMap: null), + ["octave"] = new("octave", + Keywords0: "break case catch continue do else elseif end endfor endfunction endif " + + "endparfor endswitch endwhile error for function global if otherwise parfor " + + "persistent return switch try until while", + Keywords1: null, + StyleMap: null), + + // ── Smalltalk ───────────────────────────────────────────────────── + ["smalltalk"] = new("smalltalk", + Keywords0: "self super nil true false thisContext", + Keywords1: null, + StyleMap: null), + + // ── Verilog / SystemVerilog ────────────────────────────────────── + ["verilog"] = new("verilog", + Keywords0: "always and assign automatic begin buf bufif0 bufif1 case casex casez cell " + + "cmos config deassign default defparam design disable edge else end endcase " + + "endconfig endfunction endgenerate endmodule endprimitive endspecify endtable " + + "endtask event for force forever fork function generate genvar highz0 highz1 " + + "if ifnone incdir include initial inout input instance integer join large " + + "liblist library localparam macromodule medium module nand negedge nmos nor " + + "noshowcancelled not notif0 notif1 or output parameter pmos posedge primitive " + + "pull0 pull1 pulldown pullup pulsestyle_onevent pulsestyle_ondetect rcmos " + + "real realtime reg release repeat rnmos rpmos rtran rtranif0 rtranif1 scalared " + + "showcancelled signed small specify specparam strong0 strong1 supply0 supply1 " + + "table task time tran tranif0 tranif1 tri tri0 tri1 triand trior trireg unsigned " + + "use vectored wait wand weak0 weak1 while wire wor xnor xor", + Keywords1: null, + StyleMap: null), + + // ── VHDL ───────────────────────────────────────────────────────── + ["vhdl"] = new("vhdl", + Keywords0: "abs access after alias all and architecture array assert attribute begin " + + "block body buffer bus case component configuration constant disconnect " + + "downto else elsif end entity exit file for function generate generic group " + + "guarded if impure in inertial inout is label library linkage literal loop " + + "map mod nand new next nor not null of on open or others out package port " + + "postponed procedure process pure range record register reject rem report " + + "return rol ror select severity shared signal sla sll sra srl subtype then " + + "to transport type unaffected units until use variable wait when while with " + + "xnor xor", + Keywords1: null, + StyleMap: null), + + // ── Markdown duplicate alias path covered by aliases ── + + // ── Nginx config ────────────────────────────────────────────────── + ["nginx"] = new("nginx", + Keywords0: "http server location upstream events worker_processes worker_connections " + + "listen server_name root index include proxy_pass proxy_set_header " + + "fastcgi_pass try_files return rewrite if set ssl_certificate " + + "ssl_certificate_key error_log access_log gzip add_header expires", + Keywords1: null, + StyleMap: null), + + // ── ABAP ────────────────────────────────────────────────────────── + ["abap"] = new("abap", + Keywords0: null, + Keywords1: null, + StyleMap: null), + + // ── COBOL ───────────────────────────────────────────────────────── + ["cobol"] = new("cobol", + Keywords0: null, + Keywords1: null, + StyleMap: null), + + // ── ActionScript ────────────────────────────────────────────────── + ["actionscript"] = new("cpp", + Keywords0: "as break case catch class const continue default delete do dynamic else " + + "extends false final finally for function get if implements import in " + + "instanceof interface internal is namespace native new null override " + + "package private protected public return set static super switch this " + + "throw true try typeof use var void while with", + Keywords1: null, + StyleMap: null), + + // ── Groovy ──────────────────────────────────────────────────────── + ["groovy"] = new("cpp", + Keywords0: "abstract as assert boolean break byte case catch char class const continue " + + "def default do double else enum extends false final finally float for " + + "goto if implements import in instanceof int interface long native new " + + "null package private protected public return short static strictfp super " + + "switch synchronized this throw throws trait transient true try void " + + "volatile while", + Keywords1: null, + StyleMap: null), + + // ── Gradle (groovy-based) ──────────────────────────────────────── + ["gradle"] = new("cpp", + Keywords0: "apply plugins dependencies repositories implementation api compile " + + "testImplementation runtimeOnly compileOnly classpath buildscript task " + + "ext allprojects subprojects android sourceSets", + Keywords1: null, + StyleMap: null), + + // ── Razor / cshtml (use cpp for code blocks) ───────────────────── + ["razor"] = new("cpp", + Keywords0: "model using inject inherits page layout section RenderBody RenderSection " + + "Html ViewBag ViewData if else for foreach while switch case break continue " + + "return new var await async true false null", + Keywords1: null, + StyleMap: null), + + // ── Markdown reference is via WinUIEdit-style; covered above in markdown entry ── }; // ── Aliases ─────────────────────────────────────────────────────────────── @@ -711,26 +1165,96 @@ public sealed record LexillaConfig( private static readonly Dictionary LexillaAliases = new(StringComparer.OrdinalIgnoreCase) { - ["sh"] = "bash", - ["zsh"] = "bash", - ["ksh"] = "bash", - ["fish"] = "bash", - ["shellscript"]= "bash", - ["shell"] = "bash", - ["scss"] = "css", - ["less"] = "css", - ["sass"] = "css", - ["vb"] = "vbnet", - ["visualbasic"]= "vbnet", - ["patch"] = "diff", - ["properties"] = "ini", - ["cfg"] = "ini", - ["conf"] = "ini", - ["env"] = "ini", - ["cmd"] = "batch", - ["latex"] = "cpp", // close enough for tex - ["tex"] = "cpp", - ["tsx"] = "typescript", + ["sh"] = "bash", + ["zsh"] = "bash", + ["ksh"] = "bash", + ["fish"] = "bash", + ["shellscript"] = "bash", + ["shell"] = "bash", + ["scss"] = "css", + ["less"] = "css", + ["sass"] = "css", + ["vb"] = "vbnet", + ["visualbasic"] = "vbnet", + ["patch"] = "diff", + ["properties"] = "ini", + ["cfg"] = "ini", + ["conf"] = "ini", + ["env"] = "ini", + ["dotenv"] = "ini", + ["editorconfig"] = "ini", + ["gitconfig"] = "ini", + ["cmd"] = "batch", + ["bat"] = "batch", + ["tex"] = "latex", + ["bibtex"] = "latex", + ["delphi"] = "pascal", + ["objectpascal"] = "pascal", + ["common-lisp"] = "lisp", + ["commonlisp"] = "lisp", + ["elisp"] = "lisp", + ["emacs-lisp"] = "lisp", + ["racket"] = "scheme", + ["clj"] = "clojure", + ["cljs"] = "clojure", + ["cljc"] = "clojure", + ["edn"] = "clojure", + ["systemverilog"] = "verilog", + ["sv"] = "verilog", + ["matlab-octave"] = "octave", + ["m"] = "matlab", + ["pl"] = "perl", + ["pm"] = "perl", + ["ps1"] = "powershell", + ["psm1"] = "powershell", + ["psd1"] = "powershell", + ["nu"] = "bash", // Nushell: closest available + ["dockerfile"] = "dockerfile", + ["containerfile"] = "dockerfile", + ["mk"] = "makefile", + ["gnumake"] = "makefile", + ["bazel"] = "cmake", // close enough for Starlark + ["starlark"] = "cmake", + ["bzl"] = "cmake", + ["build"] = "cmake", + ["workspace"] = "cmake", + ["terraform"] = "hcl", + ["tf"] = "hcl", + ["tfvars"] = "hcl", + ["proto"] = "protobuf", + ["proto3"] = "protobuf", + ["proto2"] = "protobuf", + ["gql"] = "graphql", + ["sol"] = "solidity", + ["mat"] = "matlab", + ["nim"] = "nim", + ["zig"] = "cpp", // map zig → cpp lexer (close enough) + ["odin"] = "cpp", + ["v"] = "cpp", + ["jl"] = "julia", + ["cr"] = "crystal", + ["ex"] = "elixir", + ["exs"] = "elixir", + ["erl"] = "erlang", + ["hrl"] = "erlang", + ["hs"] = "haskell", + ["lhs"] = "haskell", + ["ml"] = "caml", + ["mli"] = "caml", + ["fs"] = "fsharp", + ["fsi"] = "fsharp", + ["fsx"] = "fsharp", + ["nginxconf"] = "nginx", + ["razorcs"] = "razor", + ["cshtml"] = "razor", + ["vbhtml"] = "razor", + ["mips"] = "asm", + ["nasm"] = "asm", + ["gas"] = "asm", + ["masm"] = "asm", + ["x86asm"] = "asm", + ["assembly"] = "asm", + ["s"] = "asm", }; // ── Public API ──────────────────────────────────────────────────────────── From bba666ed1545c0ea5d6fb9bec834a86aa158ff6c Mon Sep 17 00:00:00 2001 From: Melody Song Date: Thu, 7 May 2026 22:30:15 -0700 Subject: [PATCH 04/21] =?UTF-8?q?feat(code-viewer):=20UI/UX=20polish=20?= =?UTF-8?q?=E2=80=94=20search,=20GridSplitter,=20README=20auto-open,=20cop?= =?UTF-8?q?y=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire file tree search: FilteredRootNodes now drives the TreeView when FilterText is non-empty (flat results); full tree shown when cleared - Add resizable panels via CommunityToolkit GridSplitter between tree and code pane; add Sizers 8.2.250402 package reference - Enable horizontal scrollbar on TreeView (ScrollViewer attached props) - Remove rounded corners from CodeEditorControl and RepoFileTreeView - Copy-path / copy-raw-URL buttons show checkmark for 1.5s then revert - Markdown: disable horizontal scroll, HorizontalAlignment=Stretch on MarkdownTextBlock to cap image width to viewport - Markdown: local theme resource overrides for inline-code and link colors - Auto-open README (md/rst/txt/adoc) when entering the code view Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- JitHub.WinUI/JitHub.WinUI.csproj | 1 + .../CodeViewer/RepoCodeBreadcrumbViewModel.cs | 20 ++++++++--- .../CodeViewer/RepoCodePageViewModel.cs | 19 +++++++++++ .../CodeViewer/CodeEditorControl.xaml | 3 +- .../CodeViewer/Renderers/MarkdownPreview.xaml | 23 ++++++++++++- .../CodeViewer/RepoCodeBreadcrumb.xaml | 4 +-- .../CodeViewer/RepoCodeBreadcrumb.xaml.cs | 29 ++++++++++++++++ .../Controls/CodeViewer/RepoFileTreeView.xaml | 9 ++--- .../CodeViewer/RepoFileTreeView.xaml.cs | 34 ++++++++++++++++--- JitHub.WinUI/Views/Pages/RepoCodePage.xaml | 22 +++++++++--- 10 files changed, 141 insertions(+), 23 deletions(-) diff --git a/JitHub.WinUI/JitHub.WinUI.csproj b/JitHub.WinUI/JitHub.WinUI.csproj index 4c9a899..433f96b 100644 --- a/JitHub.WinUI/JitHub.WinUI.csproj +++ b/JitHub.WinUI/JitHub.WinUI.csproj @@ -101,6 +101,7 @@ + diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs index 2c22327..1240b3e 100644 --- a/JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodeBreadcrumbViewModel.cs @@ -17,6 +17,12 @@ public sealed partial class RepoCodeBreadcrumbViewModel : ObservableObject [ObservableProperty] public partial string? CurrentGitHubUrl { get; set; } + [ObservableProperty] + public partial bool IsCopyPathDone { get; set; } + + [ObservableProperty] + public partial bool IsCopyRawUrlDone { get; set; } + /// /// Optional callback invoked when the user taps a breadcrumb segment. /// The page VM wires this to expand the tree to that folder. @@ -34,7 +40,7 @@ private async System.Threading.Tasks.Task NavigateToSegmentAsync(BreadcrumbSegme } [RelayCommand] - private async System.Threading.Tasks.Task CopyPathAsync() + private async System.Threading.Tasks.Task CopyPathAsync(System.Threading.CancellationToken ct) { string? path = GetCurrentFilePath(); if (path is null) return; @@ -42,18 +48,24 @@ private async System.Threading.Tasks.Task CopyPathAsync() var dp = new DataPackage(); dp.SetText(path); Clipboard.SetContent(dp); - await System.Threading.Tasks.Task.CompletedTask; + + IsCopyPathDone = true; + try { await System.Threading.Tasks.Task.Delay(1500, ct); } catch (OperationCanceledException) { } + finally { IsCopyPathDone = false; } } [RelayCommand] - private async System.Threading.Tasks.Task CopyRawUrlAsync() + private async System.Threading.Tasks.Task CopyRawUrlAsync(System.Threading.CancellationToken ct) { if (CurrentRawUrl is null) return; var dp = new DataPackage(); dp.SetText(CurrentRawUrl); Clipboard.SetContent(dp); - await System.Threading.Tasks.Task.CompletedTask; + + IsCopyRawUrlDone = true; + try { await System.Threading.Tasks.Task.Delay(1500, ct); } catch (OperationCanceledException) { } + finally { IsCopyRawUrlDone = false; } } [RelayCommand] diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs index 43ef193..e0f00cd 100644 --- a/JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoCodePageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -117,6 +118,8 @@ public async Task InitializeAsync(string owner, string name, string @ref, Cancel Tree.IsTruncated = tree.Truncated; Tree.IsLoading = false; IsLoading = false; + // Auto-open README after tree is populated. + _ = TryOpenReadmeAsync(CancellationToken.None); }); } catch (OperationCanceledException) @@ -328,6 +331,22 @@ private async Task SelectFileAsyncInternal(RepoTreeNode node, CancellationToken } } + private async Task TryOpenReadmeAsync(CancellationToken ct) + { + static bool IsReadme(string name) => + name.Equals("README.md", StringComparison.OrdinalIgnoreCase) || + name.Equals("README.rst", StringComparison.OrdinalIgnoreCase) || + name.Equals("README.txt", StringComparison.OrdinalIgnoreCase) || + name.Equals("README.adoc", StringComparison.OrdinalIgnoreCase) || + name.Equals("README", StringComparison.OrdinalIgnoreCase); + + RepoTreeNodeViewModel? readmeNode = Tree.RootNodes + .FirstOrDefault(n => !n.IsDirectory && IsReadme(n.Name)); + + if (readmeNode is not null) + await SelectFileAsync(ToModelNode(readmeNode), ct); + } + private void RunOnUi(Action action) { if (_dispatcherQueue is null || _dispatcherQueue.HasThreadAccess) diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml index b3745b9..09573ab 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml +++ b/JitHub.WinUI/Views/Controls/CodeViewer/CodeEditorControl.xaml @@ -12,8 +12,7 @@ + BorderThickness="1"> + + + + + + + + + + + + + + + + + @@ -41,8 +58,12 @@ - + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml index 54fb17c..8f3f33f 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml +++ b/JitHub.WinUI/Views/Controls/CodeViewer/RepoCodeBreadcrumb.xaml @@ -93,13 +93,13 @@ Command="{x:Bind ViewModel.CopyPathCommand, Mode=OneWay}" Style="{StaticResource AppToolbarButtonStyle}" ToolTipService.ToolTip="Copy path"> - + public sealed partial class MarkdownPreview : UserControl { + // Persistent brush instances shared with MarkdownThemes. + // On theme change we only mutate .Color — the already-rendered elements + // update in-place without a full Markdown re-render. + private readonly SolidColorBrush _inkBrush = new(); + private readonly SolidColorBrush _subtleBrush = new(); + private readonly SolidColorBrush _accentBrush = new(); + private readonly SolidColorBrush _borderBrush = new(); + private readonly SolidColorBrush _inlineCodeBg = new(); + private readonly SolidColorBrush _codeBlockBg = new(); + private readonly SolidColorBrush _tableHeadBg = new(); + private readonly MarkdownConfig _markdownConfig; + public MarkdownPreview() { InitializeComponent(); - DataContextChanged += OnDataContextChanged; - ActualThemeChanged += (_, _) => ApplyMarkdown(); + + UpdateBrushColors(ActualTheme == ElementTheme.Dark); + + _markdownConfig = new MarkdownConfig + { + Themes = new MarkdownThemes + { + H1Foreground = _inkBrush, + H2Foreground = _inkBrush, + H3Foreground = _inkBrush, + H4Foreground = _inkBrush, + H5Foreground = _inkBrush, + H6Foreground = _inkBrush, + InlineCodeBackground = _inlineCodeBg, + InlineCodeForeground = _inkBrush, + InlineCodeBorderBrush = _borderBrush, + CodeBlockBackground = _codeBlockBg, + CodeBlockForeground = _inkBrush, + CodeBlockBorderBrush = _borderBrush, + LinkForeground = _accentBrush, + QuoteForeground = _subtleBrush, + QuoteBorderBrush = _accentBrush, + BorderBrush = _borderBrush, + TableBorderBrush = _borderBrush, + TableHeadingBackground = _tableHeadBg, + HorizontalRuleBrush = _borderBrush, + // ImageMaxWidth = 0 means no theme-level cap; the LayoutUpdated + // walker applies a live, container-responsive MaxWidth instead. + ImageMaxWidth = 0, + ImageMaxHeight = 0, + ImageStretch = Stretch.Uniform, + }, + }; + + RichMarkdown.Config = _markdownConfig; + + DataContextChanged += OnDataContextChanged; + ActualThemeChanged += (_, _) => UpdateBrushColors(ActualTheme == ElementTheme.Dark); + RichMarkdown.LayoutUpdated += OnMarkdownLayoutUpdated; } private RepoFilePreviewViewModel? ViewModel => DataContext as RepoFilePreviewViewModel; @@ -27,7 +76,7 @@ private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEve { SyncSegmented(); SyncPanels(); - ApplyMarkdown(); + RichMarkdown.Text = ViewModel?.Text ?? string.Empty; Bindings.Update(); } @@ -39,7 +88,7 @@ private void SyncSegmented() private void SyncPanels() { bool rich = ViewModel?.ShowRichPreview ?? true; - RichPanel.Visibility = rich ? Visibility.Visible : Visibility.Collapsed; + RichPanel.Visibility = rich ? Visibility.Visible : Visibility.Collapsed; PlainPanel.Visibility = rich ? Visibility.Collapsed : Visibility.Visible; } @@ -53,81 +102,71 @@ private void ViewModeSegmented_SelectionChanged(object sender, SelectionChangedE SyncPanels(); } - // ── Markdown config & color theming ────────────────────────────────────── - // CT MarkdownTextBlock reads colors from MarkdownConfig.Themes (a C# object - // with direct Brush properties — not XAML resource keys). We build a fresh - // MarkdownConfig for the current theme and set it BEFORE assigning Text so - // the very first render already uses the correct colors. + // ── Color theming — mutable brushes ────────────────────────────────────── - private void ApplyMarkdown() + private void UpdateBrushColors(bool dark) { - RichMarkdown.Config = BuildConfig(); - - // Force a full re-render by clearing Text first (setting null! skips the - // render path in OnTextChanged which requires NewValue != null; then - // restoring the real text triggers a full re-render with the new config). - RichMarkdown.Text = null!; - RichMarkdown.Text = ViewModel?.Text ?? string.Empty; + _inkBrush.Color = ParseColor(dark ? "#F0F2EA" : "#223127"); + _subtleBrush.Color = ParseColor(dark ? "#99A294" : "#6D7A70"); + _accentBrush.Color = ParseColor(dark ? "#77B59A" : "#3E7B64"); + _borderBrush.Color = ParseColor(dark ? "#3C463E" : "#D5CBB7"); + _inlineCodeBg.Color = ParseColor(dark ? "#303830" : "#E8E3D8"); + _codeBlockBg.Color = ParseColor(dark ? "#1C221C" : "#EDE8DD"); + _tableHeadBg.Color = ParseColor(dark ? "#252B25" : "#F7F0E1"); } - private MarkdownConfig BuildConfig() + private static Color ParseColor(string hex) { - bool dark = ActualTheme == ElementTheme.Dark; + hex = hex.TrimStart('#'); + return Color.FromArgb(0xFF, + Convert.ToByte(hex[0..2], 16), + Convert.ToByte(hex[2..4], 16), + Convert.ToByte(hex[4..6], 16)); + } - var ink = dark ? "#F0F2EA" : "#223127"; - var accent = dark ? "#77B59A" : "#3E7B64"; - var subtle = dark ? "#99A294" : "#6D7A70"; - var border = dark ? "#3C463E" : "#D5CBB7"; - var inlineBg = dark ? "#303830" : "#E8E3D8"; - var codeBlockBg = dark ? "#1C221C" : "#EDE8DD"; - var tableHead = dark ? "#252B25" : "#F7F0E1"; + // ── Image width constraint ──────────────────────────────────────────────── + // MarkdownThemes.ImageMaxWidth is a fixed cap and cannot respond to the + // live container width. We walk the visual tree on LayoutUpdated (which fires + // after async image loads complete) to clamp MaxWidth to the container width + // and clear any fixed Height the renderer may have set (fixed Height causes + // letterboxing with Stretch.Uniform). + + private void OnMarkdownLayoutUpdated(object? sender, object e) + { + double maxW = Math.Max(0, Math.Min(RichPanel.ActualWidth - 32, 860)); + ConstrainImages(RichMarkdown, maxW); + } - return new MarkdownConfig + private static void ConstrainImages(DependencyObject parent, double maxW) + { + int count = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < count; i++) { - Themes = new MarkdownThemes + var child = VisualTreeHelper.GetChild(parent, i); + if (child is Image img) { - // Headers — use app ink color so they're always legible - H1Foreground = Brush(ink), - H2Foreground = Brush(ink), - H3Foreground = Brush(ink), - H4Foreground = Brush(ink), - H5Foreground = Brush(ink), - H6Foreground = Brush(ink), - - // Inline code - InlineCodeBackground = Brush(inlineBg), - InlineCodeForeground = Brush(ink), - - // Fenced code block - CodeBlockBackground = Brush(codeBlockBg), - CodeBlockForeground = Brush(ink), - - // Links - LinkForeground = Brush(accent), - - // Quotes, tables, rules - QuoteForeground = Brush(subtle), - QuoteBorderBrush = Brush(accent), - BorderBrush = Brush(border), - TableBorderBrush = Brush(border), - TableHeadingBackground = Brush(tableHead), - HorizontalRuleBrush = Brush(border), - - // Images — let MarkdownThemes handle sizing natively; - // no visual-tree walk needed. - ImageMaxWidth = 860, - ImageMaxHeight = 0, // 0 = no height cap; height flows naturally - ImageStretch = Stretch.Uniform, - }, - }; + // Clamp to container width. Keep the smaller of the two so we + // never upscale beyond the image's natural size. + img.MaxWidth = (img.MaxWidth > 0 && img.MaxWidth < maxW) ? img.MaxWidth : maxW; + img.Height = double.NaN; // clear any fixed height to prevent letterboxing + } + else if (child is FrameworkElement fe && ContainsImage(child)) + { + fe.Height = double.NaN; // clear fixed height on image wrapper elements too + } + ConstrainImages(child, maxW); + } } - private static SolidColorBrush Brush(string hex) + private static bool ContainsImage(DependencyObject parent) { - hex = hex.TrimStart('#'); - return new SolidColorBrush(Color.FromArgb(0xFF, - Convert.ToByte(hex[0..2], 16), - Convert.ToByte(hex[2..4], 16), - Convert.ToByte(hex[4..6], 16))); + int count = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is Image) return true; + if (ContainsImage(child)) return true; + } + return false; } } From cf5c05cd00bc9eb197313277c4fe7a9147332d45 Mon Sep 17 00:00:00 2001 From: Melody Song Date: Fri, 8 May 2026 04:43:40 -0700 Subject: [PATCH 15/21] fix(code-viewer): trigger file tree filter on each keystroke The FilterText TextBox binding was missing UpdateSourceTrigger=PropertyChanged, so the TwoWay x:Bind only wrote back on focus-lost. Adding it causes FilterText to update on every keystroke, which immediately triggers OnFilterTextChanged in the VM and RebuildTreeView in the code-behind. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml index 9434a81..fc4193a 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml +++ b/JitHub.WinUI/Views/Controls/CodeViewer/RepoFileTreeView.xaml @@ -40,7 +40,7 @@ Grid.Row="1" Margin="8,8,8,4" PlaceholderText="Filter files…" - Text="{x:Bind ViewModel.FilterText, Mode=TwoWay}" /> + Text="{x:Bind ViewModel.FilterText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> From e93ee657d07436276655a15ca0bda1542c08d5c4 Mon Sep 17 00:00:00 2001 From: Melody Song Date: Fri, 8 May 2026 04:49:34 -0700 Subject: [PATCH 16/21] perf(code-viewer): debounce and off-thread file tree search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace synchronous per-keystroke RebuildFilter() with an async, debounced pipeline that never blocks user input: - 150ms debounce via Task.Delay — typing faster than 150ms/char coalesces into a single filter run; previous in-flight searches are cancelled via CancellationTokenSource (Interlocked.Exchange pattern). - FlattenLeaves runs on a ThreadPool thread via Task.Run so the recursive tree walk never touches the UI thread. - Results are applied back on the UI thread automatically because 'await' captures the WinUI SynchronizationContext when started from the UI thread. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeViewer/RepoFileTreeViewModel.cs | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs b/JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs index be802fb..6c609f6 100644 --- a/JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs +++ b/JitHub.WinUI/ViewModels/CodeViewer/RepoFileTreeViewModel.cs @@ -37,11 +37,14 @@ public sealed partial class RepoFileTreeViewModel : ObservableObject /// /// Filtered view of RootNodes by FilterText (case-insensitive substring match on Path). - /// Updated whenever FilterText changes. + /// Updated asynchronously (debounced + off-thread) whenever FilterText changes. /// public IEnumerable FilteredRootNodes => _filteredRootNodes; - private ObservableCollection _filteredRootNodes = []; + private IEnumerable _filteredRootNodes = []; + + // Each new keystroke creates a fresh CTS; the previous in-flight filter is cancelled. + private CancellationTokenSource _filterCts = new(); // Callback wired by page VM so SelectNodeCommand routes to it. public Func? OnSelectNode { get; set; } @@ -54,7 +57,50 @@ public RepoFileTreeViewModel(IRepoTreeService treeService, ILanguageIdResolver l partial void OnFilterTextChanged(string value) { - RebuildFilter(); + _ = RebuildFilterAsync(value); + } + + private async Task RebuildFilterAsync(string filterText) + { + // Cancel any previous in-flight search and start a new one. + var cts = new CancellationTokenSource(); + var old = Interlocked.Exchange(ref _filterCts, cts); + old.Cancel(); + old.Dispose(); + + try + { + // Debounce: wait for typing to pause before doing any work. + await Task.Delay(150, cts.Token); + + string filter = filterText?.Trim() ?? string.Empty; + + IEnumerable result; + if (string.IsNullOrEmpty(filter)) + { + result = RootNodes; + } + else + { + // Snapshot the node list on the UI thread before going off-thread. + var snapshot = RootNodes.ToList(); + var flat = await Task.Run( + () => FlattenLeaves(snapshot, filter).ToList(), + cts.Token); + + cts.Token.ThrowIfCancellationRequested(); + result = flat; + } + + // Continuations after 'await' resume on the UI SynchronizationContext, + // so this PropertyChanged notification is safe to fire directly. + _filteredRootNodes = result; + OnPropertyChanged(nameof(FilteredRootNodes)); + } + catch (OperationCanceledException) + { + // A newer filter superseded this one — nothing to do. + } } [RelayCommand] @@ -102,7 +148,9 @@ public void Load(RepoTree tree, string owner, string repo, string @ref) } IsTruncated = tree.Truncated; - RebuildFilter(); + + // Trigger a filter rebuild with current FilterText (typically empty on first load). + _ = RebuildFilterAsync(FilterText); } /// Truncated-tree fallback: load children of a directory node via the REST API. @@ -147,23 +195,6 @@ private RepoTreeNodeViewModel BuildNodeVm(RepoTreeNode model, RepoTreeNodeViewMo return vm; } - private void RebuildFilter() - { - string filter = FilterText?.Trim() ?? string.Empty; - - if (string.IsNullOrEmpty(filter)) - { - _filteredRootNodes = RootNodes; - } - else - { - var flat = FlattenLeaves(RootNodes, filter); - _filteredRootNodes = new ObservableCollection(flat); - } - - OnPropertyChanged(nameof(FilteredRootNodes)); - } - private static IEnumerable FlattenLeaves( IEnumerable nodes, string filter) From b85b1a21199178b4cbbc17fb8247b4ae9c541faf Mon Sep 17 00:00:00 2001 From: Melody Song Date: Fri, 8 May 2026 04:53:31 -0700 Subject: [PATCH 17/21] fix(code-viewer): Markdown image MaxWidth grows back when window is widened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous logic kept img.MaxWidth at the shrunken container value because 'img.MaxWidth < maxW' was always true after a shrink — it never allowed the image to grow back. Fix: on the first LayoutUpdated visit to each Image, save the renderer's natural MaxWidth into img.Tag. Every subsequent visit computes min(naturalMax, containerWidth) from that saved value, so: - shrinking: clamps to the narrower container - growing: restores up to the natural image size (or 860px cap) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Renderers/MarkdownPreview.xaml.cs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs index ecd88e7..3a67267 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/MarkdownPreview.xaml.cs @@ -145,14 +145,24 @@ private static void ConstrainImages(DependencyObject parent, double maxW) var child = VisualTreeHelper.GetChild(parent, i); if (child is Image img) { - // Clamp to container width. Keep the smaller of the two so we - // never upscale beyond the image's natural size. - img.MaxWidth = (img.MaxWidth > 0 && img.MaxWidth < maxW) ? img.MaxWidth : maxW; - img.Height = double.NaN; // clear any fixed height to prevent letterboxing + // Save the renderer's natural MaxWidth the first time we visit this + // image, so we can always recompute min(natural, container) correctly. + // Without this, shrinking would overwrite MaxWidth and growing could + // never restore it. + if (img.Tag is not double naturalMax) + { + naturalMax = (img.MaxWidth > 0 && !double.IsInfinity(img.MaxWidth)) + ? img.MaxWidth + : double.PositiveInfinity; + img.Tag = naturalMax; + } + + img.MaxWidth = double.IsInfinity(naturalMax) ? maxW : Math.Min(naturalMax, maxW); + img.Height = double.NaN; } else if (child is FrameworkElement fe && ContainsImage(child)) { - fe.Height = double.NaN; // clear fixed height on image wrapper elements too + fe.Height = double.NaN; } ConstrainImages(child, maxW); } From 0c9a41dda422b3796b0747da40c50d08dde9784c Mon Sep 17 00:00:00 2001 From: Melody Song Date: Fri, 8 May 2026 05:17:11 -0700 Subject: [PATCH 18/21] ci: remove VS Code editor asset sync from CI workflows The jithub-vs-code repo, Sync-JitHubVsCodeAssets.ps1, and associated scripts are no longer part of the build now that the native code viewer replaces the WebView2/VS Code embed. - winapp-cli-smoke.yml: remove jithub-vs-code checkout, Node.js setup, yarn install, and Build editor assets step. Remove editor_assets_ref input and simplify verify_debug_build description. - jithub-store-release.yml: remove jithub-vs-code checkout, Node.js setup, yarn install, Build editor assets step, and editor_assets_ref input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/jithub-store-release.yml | 32 ------------------ .github/workflows/winapp-cli-smoke.yml | 38 +--------------------- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/.github/workflows/jithub-store-release.yml b/.github/workflows/jithub-store-release.yml index 839cce3..a7a189a 100644 --- a/.github/workflows/jithub-store-release.yml +++ b/.github/workflows/jithub-store-release.yml @@ -7,11 +7,6 @@ on: description: Four-part Store package version (for example 1.6.5.0) required: true type: string - editor_assets_ref: - description: jithub-vs-code ref to build for artifacts\EditorAssets\dist - required: false - default: master - type: string use_signing_certificate: description: Sign the package with the configured PFX before Store upload. Leave false to match the existing Store-upload flow where Partner Center re-signs the submitted package. required: false @@ -69,30 +64,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Checkout jithub-vs-code - uses: actions/checkout@v4 - with: - repository: nerocui/jithub-vs-code - ref: ${{ inputs.editor_assets_ref }} - path: editor-assets - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: yarn - cache-dependency-path: editor-assets/yarn.lock - - name: Set up .NET SDK uses: actions/setup-dotnet@v4 with: global-json-file: ./global.json - - name: Install editor asset dependencies - working-directory: editor-assets - shell: pwsh - run: yarn --frozen-lockfile - - name: Set up Microsoft Store CLI uses: microsoft/microsoft-store-apppublisher@v1.3 @@ -100,14 +76,6 @@ jobs: shell: pwsh run: msstore --help - - name: Build editor assets - shell: pwsh - run: > - .\eng\Sync-JitHubVsCodeAssets.ps1 - -VsCodeRepoPath "${{ github.workspace }}\editor-assets" - -DestinationPath "${{ github.workspace }}\artifacts\EditorAssets\dist" - -SkipInstall - - name: Patch Store manifest values shell: pwsh run: > diff --git a/.github/workflows/winapp-cli-smoke.yml b/.github/workflows/winapp-cli-smoke.yml index bd226a8..84799c5 100644 --- a/.github/workflows/winapp-cli-smoke.yml +++ b/.github/workflows/winapp-cli-smoke.yml @@ -4,15 +4,10 @@ on: workflow_dispatch: inputs: verify_debug_build: - description: Build JitHub.WinUI after installing winapp. This also builds editor assets from jithub-vs-code. + description: Build JitHub.WinUI after installing winapp. required: false default: false type: boolean - editor_assets_ref: - description: jithub-vs-code ref to build when verify_debug_build is true. - required: false - default: master - type: string permissions: contents: read @@ -41,37 +36,6 @@ jobs: winapp create-debug-identity --help winapp ui --help - - name: Checkout jithub-vs-code - if: ${{ inputs.verify_debug_build }} - uses: actions/checkout@v4 - with: - repository: nerocui/jithub-vs-code - ref: ${{ inputs.editor_assets_ref }} - path: editor-assets - - - name: Set up Node.js - if: ${{ inputs.verify_debug_build }} - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: yarn - cache-dependency-path: editor-assets/yarn.lock - - - name: Install editor asset dependencies - if: ${{ inputs.verify_debug_build }} - working-directory: editor-assets - shell: pwsh - run: yarn --frozen-lockfile - - - name: Build editor assets - if: ${{ inputs.verify_debug_build }} - shell: pwsh - run: > - .\eng\Sync-JitHubVsCodeAssets.ps1 - -VsCodeRepoPath "${{ github.workspace }}\editor-assets" - -DestinationPath "${{ github.workspace }}\artifacts\EditorAssets\dist" - -SkipInstall - - name: Build WinUI debug app if: ${{ inputs.verify_debug_build }} shell: pwsh From 2f6c2e2c1a09ed6b85165ae14896a29ab5b64461 Mon Sep 17 00:00:00 2001 From: Melody Song Date: Fri, 8 May 2026 05:21:25 -0700 Subject: [PATCH 19/21] fix(code-viewer): remove YamlDotNet to ensure AOT compatibility YamlDotNet's DeserializerBuilder/SerializerBuilder uses reflection-based type discovery which is incompatible with PublishAot=True (Release builds). Replace the YamlPreview rich/plain toggle with a simple syntax-highlighted code view (using the existing CodeEditorControl with LanguageId=yaml). YAML is already human-readable so normalization adds little value. Remove the YamlDotNet package reference from the csproj entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- JitHub.WinUI/JitHub.WinUI.csproj | 1 - .../CodeViewer/Renderers/YamlPreview.xaml | 34 ++-------- .../CodeViewer/Renderers/YamlPreview.xaml.cs | 67 +------------------ 3 files changed, 6 insertions(+), 96 deletions(-) diff --git a/JitHub.WinUI/JitHub.WinUI.csproj b/JitHub.WinUI/JitHub.WinUI.csproj index 433f96b..8f8b30d 100644 --- a/JitHub.WinUI/JitHub.WinUI.csproj +++ b/JitHub.WinUI/JitHub.WinUI.csproj @@ -99,7 +99,6 @@ - diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml index 083b7ff..aaf7f97 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml @@ -3,7 +3,6 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:codeviewer="using:JitHub.WinUI.Views.Controls.CodeViewer" - xmlns:controls="using:CommunityToolkit.WinUI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" Background="{ThemeResource AppCanvasBrush}" @@ -11,34 +10,9 @@ d:DesignWidth="800" mc:Ignorable="d"> - - - - - - - - - - - - - - - - + diff --git a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml.cs b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml.cs index 23b5c65..356c52b 100644 --- a/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml.cs +++ b/JitHub.WinUI/Views/Controls/CodeViewer/Renderers/YamlPreview.xaml.cs @@ -1,26 +1,17 @@ -using System.IO; -using System.Threading.Tasks; using JitHub.WinUI.ViewModels.CodeViewer; -using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using YamlDotNet.Serialization; namespace JitHub.WinUI.Views.Controls.CodeViewer.Renderers; /// -/// Renders YAML with optional normalization via a rich/plain toggle. -/// DataContext must be a . +/// Renders YAML with syntax highlighting. DataContext must be a . /// public sealed partial class YamlPreview : UserControl { - private readonly DispatcherQueue _dispatcher; - private string? _lastText; - public YamlPreview() { InitializeComponent(); - _dispatcher = DispatcherQueue.GetForCurrentThread(); DataContextChanged += OnDataContextChanged; } @@ -28,60 +19,6 @@ public YamlPreview() private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) { - SyncSegmented(); - UpdateContent(); - } - - private void SyncSegmented() - { - ViewModeSegmented.SelectedIndex = (ViewModel?.ShowRichPreview ?? true) ? 0 : 1; - } - - private void ViewModeSegmented_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - var vm = ViewModel; - if (vm is null) return; - bool wantsRich = ViewModeSegmented.SelectedIndex == 0; - if (vm.ShowRichPreview != wantsRich) - vm.ShowRichPreview = wantsRich; - UpdateContent(); - } - - private void UpdateContent() - { - var vm = ViewModel; - var text = vm?.Text ?? string.Empty; - var rich = vm?.ShowRichPreview ?? true; - _lastText = text; - - if (!rich) - { - Editor.Text = text; - return; - } - - Task.Run(() => - { - string normalized; - try - { - var deserializer = new DeserializerBuilder().Build(); - var obj = deserializer.Deserialize(new StringReader(text)); - var serializer = new SerializerBuilder().Build(); - normalized = serializer.Serialize(obj); - } - catch - { - normalized = text; - } - return normalized; - }).ContinueWith(t => - { - _dispatcher.TryEnqueue(() => - { - if (_lastText == text) - Editor.Text = t.Result; - }); - }, TaskScheduler.Default); + Editor.Text = ViewModel?.Text ?? string.Empty; } } From 1db19b9a8138ef4acc166fe5d5cfdc5e50e34a85 Mon Sep 17 00:00:00 2001 From: Melody Song Date: Fri, 8 May 2026 05:42:19 -0700 Subject: [PATCH 20/21] test(code-viewer): add comprehensive unit test suite for code viewer services - Add JitHub.WinUI.Tests xUnit project with 90 tests covering FilePreviewResolver, LanguageIdResolver, and RepoFileCacheService via source file linking - FilePreviewResolverTests: 43 cases - size guards, all image/markdown/csv/json/xml/ yaml/svg routing, binary detection, hex vs unsupported, case insensitivity, language id - LanguageIdResolverTests: 27 cases - extension/filename/shebang resolution, case insensitivity, compound extensions, IsKnown(), fallback to plaintext - RepoFileCacheServiceTests: 20 cases - TryGet/GetAsync/PutAsync flows, memory LRU eviction (by count and bytes), disk cap enforcement, TTL expiry, purge, binary entries - Add internal testable constructors to LanguageIdResolver and RepoFileCacheService to bypass ApplicationData.Current and JSON file loading in test context - Add CI workflow (.github/workflows/code-viewer-unit-tests.yml) that runs on push/PR and collects trx results + code coverage via coverlet Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/code-viewer-unit-tests.yml | 52 +++ JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj | 40 ++ JitHub.WinUI.Tests/NuGet.config | 7 + .../Services/FilePreviewResolverTests.cs | 380 +++++++++++++++ .../Services/LanguageIdResolverTests.cs | 235 ++++++++++ .../Services/RepoFileCacheServiceTests.cs | 431 ++++++++++++++++++ .../Services/CodeViewer/LanguageIdResolver.cs | 10 + .../CodeViewer/RepoFileCacheService.cs | 15 + JitHub.slnx | 1 + 9 files changed, 1171 insertions(+) create mode 100644 .github/workflows/code-viewer-unit-tests.yml create mode 100644 JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj create mode 100644 JitHub.WinUI.Tests/NuGet.config create mode 100644 JitHub.WinUI.Tests/Services/FilePreviewResolverTests.cs create mode 100644 JitHub.WinUI.Tests/Services/LanguageIdResolverTests.cs create mode 100644 JitHub.WinUI.Tests/Services/RepoFileCacheServiceTests.cs diff --git a/.github/workflows/code-viewer-unit-tests.yml b/.github/workflows/code-viewer-unit-tests.yml new file mode 100644 index 0000000..ba68805 --- /dev/null +++ b/.github/workflows/code-viewer-unit-tests.yml @@ -0,0 +1,52 @@ +name: Code Viewer Unit Tests + +on: + push: + branches: + - 'agents/native-code-viewer-integration' + - 'main' + pull_request: + branches: + - 'main' + +permissions: + contents: read + +jobs: + unit-tests: + name: Run code viewer unit tests + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: ./global.json + + - name: Restore dependencies + run: dotnet restore JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj -p:Platform=x64 + + - name: Build test project + run: dotnet build JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj -c Debug -p:Platform=x64 --no-restore + + - name: Run unit tests + run: > + dotnet test JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj + -c Debug + -p:Platform=x64 + --no-build + --logger "trx;LogFileName=test-results.trx" + --collect:"XPlat Code Coverage" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + **/*.trx + **/coverage.cobertura.xml diff --git a/JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj b/JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj new file mode 100644 index 0000000..8e8b675 --- /dev/null +++ b/JitHub.WinUI.Tests/JitHub.WinUI.Tests.csproj @@ -0,0 +1,40 @@ + + + net10.0-windows10.0.19041.0 + enable + false + true + x64 + x86;x64;ARM64 + win-x86 + win-x64 + win-arm64 + + $(NoWarn);CA1416 + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/JitHub.WinUI.Tests/NuGet.config b/JitHub.WinUI.Tests/NuGet.config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/JitHub.WinUI.Tests/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/JitHub.WinUI.Tests/Services/FilePreviewResolverTests.cs b/JitHub.WinUI.Tests/Services/FilePreviewResolverTests.cs new file mode 100644 index 0000000..d5835c9 --- /dev/null +++ b/JitHub.WinUI.Tests/Services/FilePreviewResolverTests.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using JitHub.Models.CodeViewer; +using JitHub.Services.CodeViewer; +using Xunit; + +namespace JitHub.WinUI.Tests.Services; + +public class FilePreviewResolverTests +{ + // ── Stub ────────────────────────────────────────────────────────────────── + + private sealed class StubLanguageIdResolver : ILanguageIdResolver + { + private readonly Dictionary _map; + private readonly string _default; + + public StubLanguageIdResolver(Dictionary? map = null, string defaultLang = "plaintext") + { + _map = map ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + _default = defaultLang; + } + + public string Resolve(string fileName, ReadOnlySpan contentSniff = default) + { + string ext = Path.GetExtension(fileName); + if (!string.IsNullOrEmpty(ext) && _map.TryGetValue(ext, out var lang)) + return lang; + return _default; + } + + public bool IsKnown(string fileName) + { + string ext = Path.GetExtension(fileName); + return !string.IsNullOrEmpty(ext) && _map.ContainsKey(ext); + } + } + + private static FilePreviewResolver CreateResolver(Dictionary? map = null) + => new FilePreviewResolver(new StubLanguageIdResolver(map)); + + private static ReadOnlyMemory TextBytes(string s) => + new ReadOnlyMemory(Encoding.UTF8.GetBytes(s)); + + private static ReadOnlyMemory BinaryBytes() => + new ReadOnlyMemory(new byte[] { 0x00, 0x01, 0x02, 0x03 }); + + private static ReadOnlyMemory HighNonPrintableBytes() + { + // 40% non-printable (control chars), above 30% threshold + var bytes = new byte[100]; + for (int i = 0; i < 40; i++) bytes[i] = 0x01; // non-printable + for (int i = 40; i < 100; i++) bytes[i] = (byte)'a'; + return new ReadOnlyMemory(bytes); + } + + // ── Size guard ─────────────────────────────────────────────────────────── + + [Fact] + public void Resolve_FileSizeExceedsMaximum_ReturnsTooLarge() + { + var resolver = CreateResolver(); + long overMax = 5L * 1024 * 1024 + 1; + var result = resolver.Resolve("file.txt", overMax, default); + Assert.Equal(RepoFilePreviewKind.TooLarge, result.Kind); + } + + [Fact] + public void Resolve_FileSizeExactlyAtMax_NotTooLarge() + { + var resolver = CreateResolver(); + long atMax = 5L * 1024 * 1024; + var result = resolver.Resolve("file.txt", atMax, TextBytes("hello")); + Assert.NotEqual(RepoFilePreviewKind.TooLarge, result.Kind); + } + + [Fact] + public void Resolve_FileSizeUnderMax_NotTooLarge() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("file.txt", 100, TextBytes("hello")); + Assert.NotEqual(RepoFilePreviewKind.TooLarge, result.Kind); + } + + // ── Image extensions ───────────────────────────────────────────────────── + + [Theory] + [InlineData(".png", "image/png")] + [InlineData(".jpg", "image/jpeg")] + [InlineData(".jpeg", "image/jpeg")] + [InlineData(".gif", "image/gif")] + [InlineData(".bmp", "image/bmp")] + [InlineData(".ico", "image/x-icon")] + [InlineData(".tif", "image/tiff")] + [InlineData(".tiff", "image/tiff")] + [InlineData(".heic", "image/heif")] + [InlineData(".heif", "image/heif")] + [InlineData(".webp", "image/webp")] + public void Resolve_ImageExtension_ReturnsImageKindWithCorrectMime(string ext, string expectedMime) + { + var resolver = CreateResolver(); + var result = resolver.Resolve($"photo{ext}", 1024, default); + Assert.Equal(RepoFilePreviewKind.Image, result.Kind); + Assert.Equal(expectedMime, result.ImageMimeType); + } + + [Fact] + public void Resolve_ImageExtension_IsLikelyBinaryTrue() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("photo.png", 1024, default); + Assert.True(result.IsLikelyBinary); + } + + // ── SVG ────────────────────────────────────────────────────────────────── + + [Fact] + public void Resolve_SvgExtension_ReturnsSvgKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("icon.svg", 512, default); + Assert.Equal(RepoFilePreviewKind.Svg, result.Kind); + Assert.Equal("xml", result.LanguageId); + Assert.False(result.IsLikelyBinary); + } + + // ── Markdown ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(".md")] + [InlineData(".markdown")] + [InlineData(".mdx")] + public void Resolve_MarkdownExtension_ReturnsMarkdownKind(string ext) + { + var resolver = CreateResolver(); + var result = resolver.Resolve($"README{ext}", 100, default); + Assert.Equal(RepoFilePreviewKind.Markdown, result.Kind); + Assert.Equal("markdown", result.LanguageId); + } + + // ── CSV / TSV ───────────────────────────────────────────────────────────── + + [Fact] + public void Resolve_CsvExtension_ReturnsCsvKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("data.csv", 100, default); + Assert.Equal(RepoFilePreviewKind.Csv, result.Kind); + } + + [Fact] + public void Resolve_TsvExtension_ReturnsCsvKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("data.tsv", 100, default); + Assert.Equal(RepoFilePreviewKind.Csv, result.Kind); + } + + // ── JSON ────────────────────────────────────────────────────────────────── + + [Fact] + public void Resolve_JsonExtension_ReturnsJsonKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("config.json", 100, default); + Assert.Equal(RepoFilePreviewKind.Json, result.Kind); + Assert.Equal("json", result.LanguageId); + } + + [Fact] + public void Resolve_Json5Extension_ReturnsJsonKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("config.json5", 100, default); + Assert.Equal(RepoFilePreviewKind.Json, result.Kind); + } + + // ── XML family ──────────────────────────────────────────────────────────── + + [Theory] + [InlineData(".xml")] + [InlineData(".xsd")] + [InlineData(".xslt")] + [InlineData(".html")] + [InlineData(".htm")] + public void Resolve_XmlFamilyExtension_ReturnsXmlKind(string ext) + { + var resolver = CreateResolver(); + var result = resolver.Resolve($"file{ext}", 100, default); + Assert.Equal(RepoFilePreviewKind.Xml, result.Kind); + Assert.Equal("xml", result.LanguageId); + } + + // ── YAML ────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(".yml")] + [InlineData(".yaml")] + public void Resolve_YamlExtension_ReturnsYamlKind(string ext) + { + var resolver = CreateResolver(); + var result = resolver.Resolve($"config{ext}", 100, default); + Assert.Equal(RepoFilePreviewKind.Yaml, result.Kind); + Assert.Equal("yaml", result.LanguageId); + } + + // ── Binary detection ───────────────────────────────────────────────────── + + [Fact] + public void Resolve_NullByteInSample_DetectedAsBinary() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("file.bin", 100, BinaryBytes()); + Assert.True(result.IsLikelyBinary); + } + + [Fact] + public void Resolve_HighNonPrintableRatio_DetectedAsBinary() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("file.bin", 100, HighNonPrintableBytes()); + Assert.True(result.IsLikelyBinary); + } + + [Fact] + public void Resolve_CleanTextSample_NotDetectedAsBinary() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("file.txt", 100, TextBytes("hello world, this is clean text")); + Assert.False(result.IsLikelyBinary); + } + + // ── Binary size routing ────────────────────────────────────────────────── + + [Fact] + public void Resolve_BinarySmallFile_ReturnsHex() + { + var resolver = CreateResolver(); + long smallSize = 100; + var result = resolver.Resolve("file.bin", smallSize, BinaryBytes()); + Assert.Equal(RepoFilePreviewKind.Hex, result.Kind); + } + + [Fact] + public void Resolve_BinaryLargeFile_ReturnsUnsupported() + { + var resolver = CreateResolver(); + long largeSize = 256L * 1024 + 1; // just over 256KB + var result = resolver.Resolve("file.bin", largeSize, BinaryBytes()); + Assert.Equal(RepoFilePreviewKind.Unsupported, result.Kind); + } + + [Fact] + public void Resolve_BinaryExactlyAtHexBoundary_ReturnsHex() + { + var resolver = CreateResolver(); + long atBoundary = 256L * 1024; // exactly 256KB + var result = resolver.Resolve("file.bin", atBoundary, BinaryBytes()); + Assert.Equal(RepoFilePreviewKind.Hex, result.Kind); + } + + [Fact] + public void Resolve_BinaryJustOverHexBoundary_ReturnsUnsupported() + { + var resolver = CreateResolver(); + long justOver = 256L * 1024 + 1; + var result = resolver.Resolve("file.bin", justOver, BinaryBytes()); + Assert.Equal(RepoFilePreviewKind.Unsupported, result.Kind); + } + + // ── Code / language id ──────────────────────────────────────────────────── + + [Fact] + public void Resolve_UnknownExtensionNonBinary_ReturnsCodeKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("file.xyz", 100, TextBytes("some code here")); + Assert.Equal(RepoFilePreviewKind.Code, result.Kind); + } + + [Fact] + public void Resolve_CsExtension_ReturnsCodeWithCsharpLanguageId() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [".cs"] = "csharp" }; + var resolver = CreateResolver(map); + var result = resolver.Resolve("Program.cs", 100, TextBytes("using System;")); + Assert.Equal(RepoFilePreviewKind.Code, result.Kind); + Assert.Equal("csharp", result.LanguageId); + } + + [Fact] + public void Resolve_UnknownExtensionNonBinary_LanguageIdFromResolver() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("file.xyz", 100, TextBytes("content")); + Assert.Equal("plaintext", result.LanguageId); + } + + // ── Case insensitivity ──────────────────────────────────────────────────── + + [Fact] + public void Resolve_UpperCasePngExtension_ReturnsImageKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("photo.PNG", 1024, default); + Assert.Equal(RepoFilePreviewKind.Image, result.Kind); + } + + [Fact] + public void Resolve_MixedCaseJpegExtension_ReturnsImageKind() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("photo.JpEg", 1024, default); + Assert.Equal(RepoFilePreviewKind.Image, result.Kind); + } + + [Fact] + public void Resolve_UpperCaseCsExtension_ReturnsCodeWithCsharpLanguageId() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase) { [".cs"] = "csharp" }; + var resolver = CreateResolver(map); + var result = resolver.Resolve("Program.CS", 100, TextBytes("code")); + Assert.Equal(RepoFilePreviewKind.Code, result.Kind); + Assert.Equal("csharp", result.LanguageId); + } + + // ── Descriptor properties ───────────────────────────────────────────────── + + [Fact] + public void Resolve_TooLarge_IsLikelyBinaryFalse() + { + var resolver = CreateResolver(); + long overMax = 5L * 1024 * 1024 + 1; + var result = resolver.Resolve("file.txt", overMax, default); + Assert.False(result.IsLikelyBinary); + } + + [Fact] + public void Resolve_SvgFile_ImageMimeTypeNull() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("icon.svg", 100, default); + Assert.Null(result.ImageMimeType); + } + + [Fact] + public void Resolve_MarkdownFile_IsLikelyBinaryFalse() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("README.md", 100, default); + Assert.False(result.IsLikelyBinary); + } + + [Fact] + public void Resolve_BinaryHex_IsLikelyBinaryTrue() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("data.bin", 100, BinaryBytes()); + Assert.True(result.IsLikelyBinary); + } + + [Fact] + public void Resolve_EmptySample_NotBinary() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("file.txt", 0, default); + Assert.Equal(RepoFilePreviewKind.Code, result.Kind); + Assert.False(result.IsLikelyBinary); + } + + [Fact] + public void Resolve_JsonFile_ImageMimeTypeNull() + { + var resolver = CreateResolver(); + var result = resolver.Resolve("data.json", 100, default); + Assert.Null(result.ImageMimeType); + } +} diff --git a/JitHub.WinUI.Tests/Services/LanguageIdResolverTests.cs b/JitHub.WinUI.Tests/Services/LanguageIdResolverTests.cs new file mode 100644 index 0000000..c53ee27 --- /dev/null +++ b/JitHub.WinUI.Tests/Services/LanguageIdResolverTests.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Text; +using JitHub.Services.CodeViewer; +using Xunit; + +namespace JitHub.WinUI.Tests.Services; + +public class LanguageIdResolverTests +{ + private static LanguageIdResolver CreateResolver() + { + var extensions = new Dictionary + { + [".cs"] = "csharp", + [".py"] = "python", + [".js"] = "javascript", + [".ts"] = "typescript", + [".json"] = "json", + [".xml"] = "xml", + [".sh"] = "bash", + }; + var filenames = new Dictionary + { + ["Makefile"] = "makefile", + ["Dockerfile"] = "dockerfile", + }; + var interpreters = new Dictionary + { + ["python"] = "python", + ["python3"] = "python", + ["bash"] = "bash", + ["sh"] = "bash", + ["node"] = "javascript", + }; + return new LanguageIdResolver(extensions, filenames, interpreters); + } + + private static ReadOnlySpan Shebang(string line) + => Encoding.UTF8.GetBytes($"#!/{line}\n"); + + // ── Extension resolution ───────────────────────────────────────────────── + + [Fact] + public void Resolve_CsExtension_ReturnsCsharp() + { + var r = CreateResolver(); + Assert.Equal("csharp", r.Resolve("Program.cs")); + } + + [Fact] + public void Resolve_PyExtension_ReturnsPython() + { + var r = CreateResolver(); + Assert.Equal("python", r.Resolve("script.py")); + } + + [Fact] + public void Resolve_JsExtension_ReturnsJavascript() + { + var r = CreateResolver(); + Assert.Equal("javascript", r.Resolve("app.js")); + } + + [Fact] + public void Resolve_TsExtension_ReturnsTypescript() + { + var r = CreateResolver(); + Assert.Equal("typescript", r.Resolve("index.ts")); + } + + // ── Case insensitivity ──────────────────────────────────────────────────── + + [Fact] + public void Resolve_UpperCaseCsExtension_ReturnsCsharp() + { + var r = CreateResolver(); + Assert.Equal("csharp", r.Resolve("Program.CS")); + } + + [Fact] + public void Resolve_MixedCasePyExtension_ReturnsPython() + { + var r = CreateResolver(); + Assert.Equal("python", r.Resolve("script.Py")); + } + + // ── Unknown extension fallback ──────────────────────────────────────────── + + [Fact] + public void Resolve_UnknownExtension_ReturnsPlaintext() + { + var r = CreateResolver(); + Assert.Equal("plaintext", r.Resolve("file.xyz")); + } + + // ── Empty / null path ──────────────────────────────────────────────────── + + [Fact] + public void Resolve_EmptyPath_ReturnsPlaintext() + { + var r = CreateResolver(); + Assert.Equal("plaintext", r.Resolve(string.Empty)); + } + + [Fact] + public void Resolve_NullPath_ReturnsPlaintext() + { + var r = CreateResolver(); + Assert.Equal("plaintext", r.Resolve(null!)); + } + + // ── Filename-based resolution ───────────────────────────────────────────── + + [Fact] + public void Resolve_MakefileFilename_ReturnsMakefile() + { + var r = CreateResolver(); + Assert.Equal("makefile", r.Resolve("Makefile")); + } + + [Fact] + public void Resolve_DockerfileFilename_ReturnsDockerfile() + { + var r = CreateResolver(); + Assert.Equal("dockerfile", r.Resolve("Dockerfile")); + } + + [Fact] + public void Resolve_DockerfileInSubdir_ReturnsDockerfile() + { + var r = CreateResolver(); + Assert.Equal("dockerfile", r.Resolve("src/Dockerfile")); + } + + // ── Shebang detection ───────────────────────────────────────────────────── + + [Fact] + public void Resolve_ShebangPython_ReturnsPython() + { + var r = CreateResolver(); + var shebang = Encoding.UTF8.GetBytes("#!/usr/bin/python\n"); + Assert.Equal("python", r.Resolve("script", shebang)); + } + + [Fact] + public void Resolve_ShebangEnvPython3_ReturnsPython() + { + var r = CreateResolver(); + var shebang = Encoding.UTF8.GetBytes("#!/usr/bin/env python3\n"); + Assert.Equal("python", r.Resolve("script", shebang)); + } + + [Fact] + public void Resolve_ShebangBinBash_ReturnsBash() + { + var r = CreateResolver(); + var shebang = Encoding.UTF8.GetBytes("#!/bin/bash\n"); + Assert.Equal("bash", r.Resolve("script", shebang)); + } + + [Fact] + public void Resolve_ShebangAbsolutePathNode_ReturnsJavascript() + { + var r = CreateResolver(); + var shebang = Encoding.UTF8.GetBytes("#!/usr/local/bin/node\n"); + Assert.Equal("javascript", r.Resolve("script", shebang)); + } + + [Fact] + public void Resolve_ShebangWithArgs_StripArgs() + { + var r = CreateResolver(); + var shebang = Encoding.UTF8.GetBytes("#!/usr/bin/env python3 -u\n"); + Assert.Equal("python", r.Resolve("script", shebang)); + } + + [Fact] + public void Resolve_ShebangNotAtStart_NoMatch() + { + var r = CreateResolver(); + var content = Encoding.UTF8.GetBytes("# comment\n#!/usr/bin/python\n"); + // file has no known extension, shebang not at start → fallback + Assert.Equal("plaintext", r.Resolve("script", content)); + } + + [Fact] + public void Resolve_UnknownShebangInterpreter_ReturnsPlaintext() + { + var r = CreateResolver(); + var shebang = Encoding.UTF8.GetBytes("#!/usr/bin/ruby\n"); + Assert.Equal("plaintext", r.Resolve("script", shebang)); + } + + // ── Extension overrides shebang when extension known ───────────────────── + + [Fact] + public void Resolve_KnownExtensionWithShebang_ExtensionWins() + { + var r = CreateResolver(); + var shebang = Encoding.UTF8.GetBytes("#!/usr/bin/python\n"); + // .sh extension should resolve before shebang + Assert.Equal("bash", r.Resolve("deploy.sh", shebang)); + } + + // ── IsKnown ─────────────────────────────────────────────────────────────── + + [Fact] + public void IsKnown_KnownExtension_ReturnsTrue() + { + var r = CreateResolver(); + Assert.True(r.IsKnown("Program.cs")); + } + + [Fact] + public void IsKnown_KnownFilename_ReturnsTrue() + { + var r = CreateResolver(); + Assert.True(r.IsKnown("Makefile")); + } + + [Fact] + public void IsKnown_UnknownExtension_ReturnsFalse() + { + var r = CreateResolver(); + Assert.False(r.IsKnown("file.xyz")); + } + + [Fact] + public void IsKnown_EmptyPath_ReturnsFalse() + { + var r = CreateResolver(); + Assert.False(r.IsKnown(string.Empty)); + } +} diff --git a/JitHub.WinUI.Tests/Services/RepoFileCacheServiceTests.cs b/JitHub.WinUI.Tests/Services/RepoFileCacheServiceTests.cs new file mode 100644 index 0000000..a6d482d --- /dev/null +++ b/JitHub.WinUI.Tests/Services/RepoFileCacheServiceTests.cs @@ -0,0 +1,431 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using JitHub.Models.CodeViewer; +using JitHub.Services.CodeViewer; +using Xunit; + +namespace JitHub.WinUI.Tests.Services; + +public class RepoFileCacheServiceTests : IDisposable +{ + private readonly string _diskRoot; + + public RepoFileCacheServiceTests() + { + _diskRoot = Path.Combine(Path.GetTempPath(), "JitHubTests", Guid.NewGuid().ToString()); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_diskRoot)) + Directory.Delete(_diskRoot, recursive: true); + } + catch { /* best-effort cleanup */ } + } + + private RepoFileCacheService CreateCache( + int memMaxEntries = 256, + long memMaxBytes = 64L * 1024 * 1024, + long diskMaxBytes = 256L * 1024 * 1024, + TimeSpan ttl = default) + { + if (ttl == default) ttl = TimeSpan.FromDays(7); + return new RepoFileCacheService(memMaxEntries, memMaxBytes, diskMaxBytes, ttl, _diskRoot); + } + + private static RepoFileCacheEntry MakeEntry(string sha, byte[] data, bool isBinary = false) + => new RepoFileCacheEntry + { + Sha = sha, + ByteLength = data.Length, + IsBinary = isBinary, + Bytes = data, + Text = isBinary ? null : Encoding.UTF8.GetString(data), + Encoding = "utf-8", + CachedAt = DateTimeOffset.UtcNow, + }; + + private static RepoFileCacheKey Key(string sha, string owner = "owner", string repo = "repo") + => new RepoFileCacheKey(owner, repo, sha); + + // ── TryGet ──────────────────────────────────────────────────────────────── + + [Fact] + public void TryGet_EmptyCache_ReturnsFalse() + { + var cache = CreateCache(); + var found = cache.TryGet(Key("abc123"), out var entry); + Assert.False(found); + Assert.Null(entry); + } + + // ── PutAsync + TryGet ───────────────────────────────────────────────────── + + [Fact] + public async Task PutAsync_ThenTryGet_ReturnsEntry() + { + var cache = CreateCache(); + var data = Encoding.UTF8.GetBytes("hello"); + var key = Key("sha1"); + await cache.PutAsync(key, MakeEntry("sha1", data), CancellationToken.None); + + var found = cache.TryGet(key, out var entry); + Assert.True(found); + Assert.Equal("sha1", entry.Sha); + } + + // ── PutAsync + GetAsync (memory fast path) ──────────────────────────────── + + [Fact] + public async Task PutAsync_ThenGetAsync_ReturnsFromMemory() + { + var cache = CreateCache(); + var data = Encoding.UTF8.GetBytes("hello world"); + var key = Key("sha2"); + await cache.PutAsync(key, MakeEntry("sha2", data), CancellationToken.None); + + var result = await cache.GetAsync(key, CancellationToken.None); + Assert.NotNull(result); + Assert.Equal("sha2", result!.Sha); + Assert.Equal("hello world", result.Text); + } + + // ── Disk load after memory eviction ────────────────────────────────────── + + [Fact] + public async Task GetAsync_AfterMemoryEviction_LoadsFromDisk() + { + // Memory cap of 1 entry, so putting a second evicts the first + var cache = CreateCache(memMaxEntries: 1, memMaxBytes: 64L * 1024 * 1024); + var data = Encoding.UTF8.GetBytes("original data"); + var key1 = Key("sha-a"); + var key2 = Key("sha-b"); + + await cache.PutAsync(key1, MakeEntry("sha-a", data), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("sha-b", Encoding.UTF8.GetBytes("second")), CancellationToken.None); + + // key1 was evicted from memory but should still be on disk + var found = cache.TryGet(key1, out _); + Assert.False(found); + + var result = await cache.GetAsync(key1, CancellationToken.None); + Assert.NotNull(result); + Assert.Equal("sha-a", result!.Sha); + Assert.Equal("original data", result.Text); + } + + // ── TTL expiry ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetAsync_TtlExpired_ReturnsNull() + { + // Use a cache with 1 entry memory cap so it goes to disk, and 1 second TTL + var cache = CreateCache(memMaxEntries: 1, ttl: TimeSpan.FromMilliseconds(1)); + + var data = Encoding.UTF8.GetBytes("data"); + var key1 = Key("sha-ttl-a"); + var key2 = Key("sha-ttl-b"); + + var entry1 = new RepoFileCacheEntry + { + Sha = "sha-ttl-a", + ByteLength = data.Length, + IsBinary = false, + Bytes = data, + Text = "data", + Encoding = "utf-8", + CachedAt = DateTimeOffset.UtcNow.AddDays(-8), // expired + }; + + await cache.PutAsync(key1, entry1, CancellationToken.None); + // Put key2 to evict key1 from memory + await cache.PutAsync(key2, MakeEntry("sha-ttl-b", Encoding.UTF8.GetBytes("b")), CancellationToken.None); + + var result = await cache.GetAsync(key1, CancellationToken.None); + Assert.Null(result); + } + + [Fact] + public async Task GetAsync_TtlNotExpired_ReturnsEntry() + { + var cache = CreateCache(memMaxEntries: 1, ttl: TimeSpan.FromDays(30)); + var data = Encoding.UTF8.GetBytes("fresh data"); + var key1 = Key("sha-fresh-a"); + var key2 = Key("sha-fresh-b"); + + await cache.PutAsync(key1, MakeEntry("sha-fresh-a", data), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("sha-fresh-b", Encoding.UTF8.GetBytes("b")), CancellationToken.None); + + var result = await cache.GetAsync(key1, CancellationToken.None); + Assert.NotNull(result); + Assert.Equal("sha-fresh-a", result!.Sha); + } + + // ── LRU eviction by count ───────────────────────────────────────────────── + + [Fact] + public async Task MemoryLru_EvictionByCount_OldestEvictedFromMemory() + { + var cache = CreateCache(memMaxEntries: 2, memMaxBytes: 64L * 1024 * 1024); + var key1 = Key("lru-a"); + var key2 = Key("lru-b"); + var key3 = Key("lru-c"); + + await cache.PutAsync(key1, MakeEntry("lru-a", Encoding.UTF8.GetBytes("a")), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("lru-b", Encoding.UTF8.GetBytes("b")), CancellationToken.None); + await cache.PutAsync(key3, MakeEntry("lru-c", Encoding.UTF8.GetBytes("c")), CancellationToken.None); + + // key1 is oldest and should be evicted from memory + Assert.False(cache.TryGet(key1, out _)); + // key2 and key3 should still be in memory + Assert.True(cache.TryGet(key2, out _)); + Assert.True(cache.TryGet(key3, out _)); + } + + [Fact] + public async Task MemoryLru_EvictedByCount_StillOnDisk() + { + var cache = CreateCache(memMaxEntries: 2, memMaxBytes: 64L * 1024 * 1024); + var key1 = Key("disk-a"); + var key2 = Key("disk-b"); + var key3 = Key("disk-c"); + + await cache.PutAsync(key1, MakeEntry("disk-a", Encoding.UTF8.GetBytes("a")), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("disk-b", Encoding.UTF8.GetBytes("b")), CancellationToken.None); + await cache.PutAsync(key3, MakeEntry("disk-c", Encoding.UTF8.GetBytes("c")), CancellationToken.None); + + // key1 evicted from memory but still on disk + var result = await cache.GetAsync(key1, CancellationToken.None); + Assert.NotNull(result); + } + + // ── LRU eviction by bytes ───────────────────────────────────────────────── + + [Fact] + public async Task MemoryLru_EvictionByBytes_SmallEntryEvicted() + { + // Allow 2 entries but only 20 bytes — small entry gets evicted when larger one arrives + var cache = CreateCache(memMaxEntries: 256, memMaxBytes: 20); + + var small = Encoding.UTF8.GetBytes("hi"); // 2 bytes + var large = Encoding.UTF8.GetBytes("0123456789012345678"); // 19 bytes + + var key1 = Key("bytes-a"); + var key2 = Key("bytes-b"); + + await cache.PutAsync(key1, MakeEntry("bytes-a", small), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("bytes-b", large), CancellationToken.None); + + // key1 should be evicted because 2+19=21 > 20 bytes cap + Assert.False(cache.TryGet(key1, out _)); + Assert.True(cache.TryGet(key2, out _)); + } + + // ── LRU promotes MRU ───────────────────────────────────────────────────── + + [Fact] + public async Task MemoryLru_AccessPromotesEntry_EvictsOtherInstead() + { + // cap=2, put A, put B, get A (promotes A to MRU), put C (evicts B not A) + var cache = CreateCache(memMaxEntries: 2, memMaxBytes: 64L * 1024 * 1024); + + var keyA = Key("mru-a"); + var keyB = Key("mru-b"); + var keyC = Key("mru-c"); + + await cache.PutAsync(keyA, MakeEntry("mru-a", Encoding.UTF8.GetBytes("a")), CancellationToken.None); + await cache.PutAsync(keyB, MakeEntry("mru-b", Encoding.UTF8.GetBytes("b")), CancellationToken.None); + + // Promote A to MRU + cache.TryGet(keyA, out _); + + // Now put C — B should be evicted (LRU), A should stay + await cache.PutAsync(keyC, MakeEntry("mru-c", Encoding.UTF8.GetBytes("c")), CancellationToken.None); + + Assert.True(cache.TryGet(keyA, out _)); + Assert.False(cache.TryGet(keyB, out _)); + Assert.True(cache.TryGet(keyC, out _)); + } + + // ── Disk capacity ───────────────────────────────────────────────────────── + + [Fact] + public async Task DiskCap_Enforced_OldestEvictedFromDisk() + { + // Small disk cap (10 bytes). Two entries of 6 bytes each = 12 > 10. + // Oldest (A) should be evicted after enforcement. + var cache = CreateCache(memMaxEntries: 1, memMaxBytes: 64L * 1024 * 1024, diskMaxBytes: 10); + + var dataA = Encoding.UTF8.GetBytes("aaaaaa"); // 6 bytes, older + var dataB = Encoding.UTF8.GetBytes("bbbbbb"); // 6 bytes, newer + + var keyA = new RepoFileCacheKey("owner", "repo", "cap-a"); + var keyB = new RepoFileCacheKey("owner", "repo", "cap-b"); + + var entryA = new RepoFileCacheEntry + { + Sha = "cap-a", + ByteLength = dataA.Length, + IsBinary = false, + Bytes = dataA, + Text = "aaaaaa", + Encoding = "utf-8", + CachedAt = DateTimeOffset.UtcNow.AddMinutes(-10), // older + }; + var entryB = new RepoFileCacheEntry + { + Sha = "cap-b", + ByteLength = dataB.Length, + IsBinary = false, + Bytes = dataB, + Text = "bbbbbb", + Encoding = "utf-8", + CachedAt = DateTimeOffset.UtcNow, // newer + }; + + await cache.PutAsync(keyA, entryA, CancellationToken.None); + // keyB evicts keyA from memory (memMaxEntries=1) + await cache.PutAsync(keyB, entryB, CancellationToken.None); + + // Explicitly enforce disk cap + await cache.PurgeAsync(CancellationToken.None); + + // keyA should be evicted from disk (it's older and 6+6=12 > 10 bytes cap) + // keyA is not in memory, so GetAsync goes to disk → should be null + var result = await cache.GetAsync(keyA, CancellationToken.None); + Assert.Null(result); + } + + // ── PurgeAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task PurgeAsync_RemovesExpiredEntries() + { + var cache = CreateCache(memMaxEntries: 1, ttl: TimeSpan.FromDays(7)); + + var data = Encoding.UTF8.GetBytes("old data"); + var keyExpired = Key("purge-expired"); + var keyOther = Key("purge-other"); + + var expiredEntry = new RepoFileCacheEntry + { + Sha = "purge-expired", + ByteLength = data.Length, + IsBinary = false, + Bytes = data, + Text = "old data", + Encoding = "utf-8", + CachedAt = DateTimeOffset.UtcNow.AddDays(-8), // expired (8 > 7 days TTL) + }; + await cache.PutAsync(keyExpired, expiredEntry, CancellationToken.None); + // Evict expired entry from memory by putting another entry + await cache.PutAsync(keyOther, MakeEntry("purge-other", Encoding.UTF8.GetBytes("x")), CancellationToken.None); + + // Purge should remove the expired disk entry + await cache.PurgeAsync(CancellationToken.None); + + // After purge, GetAsync should not find expired entry (not in memory, deleted from disk) + var result = await cache.GetAsync(keyExpired, CancellationToken.None); + Assert.Null(result); + } + + [Fact] + public async Task PurgeAsync_KeepsValidEntries() + { + var cache = CreateCache(memMaxEntries: 1, ttl: TimeSpan.FromDays(7)); + + var data = Encoding.UTF8.GetBytes("valid data"); + var key1 = Key("purge-valid"); + var key2 = Key("purge-evict-mem"); + + await cache.PutAsync(key1, MakeEntry("purge-valid", data), CancellationToken.None); + // Evict key1 from memory + await cache.PutAsync(key2, MakeEntry("purge-evict-mem", Encoding.UTF8.GetBytes("x")), CancellationToken.None); + + await cache.PurgeAsync(CancellationToken.None); + + // key1 should still be retrievable from disk + var result = await cache.GetAsync(key1, CancellationToken.None); + Assert.NotNull(result); + Assert.Equal("purge-valid", result!.Sha); + } + + // ── Same SHA updates ────────────────────────────────────────────────────── + + [Fact] + public async Task PutAsync_SameSha_UpdatesCorrectly() + { + var cache = CreateCache(); + var key = Key("update-sha"); + + await cache.PutAsync(key, MakeEntry("update-sha", Encoding.UTF8.GetBytes("v1")), CancellationToken.None); + await cache.PutAsync(key, MakeEntry("update-sha", Encoding.UTF8.GetBytes("v2")), CancellationToken.None); + + var result = await cache.GetAsync(key, CancellationToken.None); + Assert.NotNull(result); + Assert.Equal("v2", result!.Text); + } + + // ── Different owner/repo same SHA ───────────────────────────────────────── + + [Fact] + public async Task PutAsync_DifferentOwnerRepoSameSha_SeparateCacheEntries() + { + var cache = CreateCache(); + + var key1 = new RepoFileCacheKey("ownerA", "repo", "same-sha"); + var key2 = new RepoFileCacheKey("ownerB", "repo", "same-sha"); + + await cache.PutAsync(key1, MakeEntry("same-sha", Encoding.UTF8.GetBytes("data-A")), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("same-sha", Encoding.UTF8.GetBytes("data-B")), CancellationToken.None); + + cache.TryGet(key1, out var entry1); + cache.TryGet(key2, out var entry2); + + Assert.Equal("data-A", entry1.Text); + Assert.Equal("data-B", entry2.Text); + } + + // ── Text and binary entries ─────────────────────────────────────────────── + + [Fact] + public async Task TextEntry_DecodedCorrectly() + { + var cache = CreateCache(memMaxEntries: 1); + var text = "Hello, 世界!"; + var data = Encoding.UTF8.GetBytes(text); + var key1 = Key("text-entry"); + var key2 = Key("text-evict"); + + await cache.PutAsync(key1, MakeEntry("text-entry", data, isBinary: false), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("text-evict", Encoding.UTF8.GetBytes("x")), CancellationToken.None); + + var result = await cache.GetAsync(key1, CancellationToken.None); + Assert.NotNull(result); + Assert.False(result!.IsBinary); + Assert.Equal(text, result.Text); + } + + [Fact] + public async Task BinaryEntry_BytesPreserved() + { + var cache = CreateCache(memMaxEntries: 1); + var binaryData = new byte[] { 0x00, 0x01, 0x02, 0xFF, 0xFE }; + var key1 = Key("binary-entry"); + var key2 = Key("binary-evict"); + + await cache.PutAsync(key1, MakeEntry("binary-entry", binaryData, isBinary: true), CancellationToken.None); + await cache.PutAsync(key2, MakeEntry("binary-evict", Encoding.UTF8.GetBytes("x")), CancellationToken.None); + + var result = await cache.GetAsync(key1, CancellationToken.None); + Assert.NotNull(result); + Assert.True(result!.IsBinary); + Assert.Equal(binaryData, result.Bytes); + Assert.Null(result.Text); + } +} diff --git a/JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs b/JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs index 3cd2794..51253b0 100644 --- a/JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs +++ b/JitHub.WinUI/Services/CodeViewer/LanguageIdResolver.cs @@ -39,6 +39,16 @@ public LanguageIdResolver() _interpreterMap = BuildCaseInsensitive(data?.Interpreters); } + internal LanguageIdResolver( + Dictionary? extensions, + Dictionary? filenames, + Dictionary? interpreters) + { + _extensionMap = BuildCaseInsensitive(extensions); + _filenameMap = BuildCaseInsensitive(filenames); + _interpreterMap = BuildCaseInsensitive(interpreters); + } + /// public string Resolve(string fileName, ReadOnlySpan contentSniff = default) { diff --git a/JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs b/JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs index a01352f..bc9f301 100644 --- a/JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs +++ b/JitHub.WinUI/Services/CodeViewer/RepoFileCacheService.cs @@ -56,6 +56,21 @@ public RepoFileCacheService(int memMaxEntries, long memMaxBytes, long diskMaxByt _ = Task.Run(() => PurgeAsync(CancellationToken.None)); } + internal RepoFileCacheService( + int memMaxEntries, + long memMaxBytes, + long diskMaxBytes, + TimeSpan ttl, + string diskRoot) + { + _memMaxEntries = memMaxEntries; + _memMaxBytes = memMaxBytes; + _diskMaxBytes = diskMaxBytes; + _ttl = ttl; + _diskRoot = diskRoot; + Directory.CreateDirectory(_diskRoot); + } + // ── Public API ─────────────────────────────────────────────────────────── public bool TryGet(RepoFileCacheKey key, out RepoFileCacheEntry entry) diff --git a/JitHub.slnx b/JitHub.slnx index 8c1f329..6a36bbe 100644 --- a/JitHub.slnx +++ b/JitHub.slnx @@ -20,4 +20,5 @@ + From 128d206d539dc4327c82c42bb5fdf65e3bcb13fd Mon Sep 17 00:00:00 2001 From: Melody Song Date: Fri, 8 May 2026 09:47:49 -0700 Subject: [PATCH 21/21] fix(slnx): add platform mappings for JitHub.WinUI.Tests project Map ARM/Any CPU solution platforms to the project's actual configurations (x86/x64/ARM64) to prevent the 'configuration does not exist' warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- JitHub.slnx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/JitHub.slnx b/JitHub.slnx index 6a36bbe..fc12503 100644 --- a/JitHub.slnx +++ b/JitHub.slnx @@ -20,5 +20,12 @@ - + + + + + + + +