diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index a9273e5..f58de8f 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -155,13 +155,32 @@ jobs: dotnet-version: 10.0.x - name: Restore - run: dotnet restore Devolutions.Terminal.slnx -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + run: dotnet restore Devolutions.Terminal.slnx -p:Configuration=Release -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Build - run: dotnet build Devolutions.Terminal.slnx -c Release --no-restore -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + run: dotnet build Devolutions.Terminal.slnx -c Release --no-restore -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Test - run: dotnet test Devolutions.Terminal.slnx -c Release --no-build -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + # Engine tests resolve the separately installed runtime; it is not a DT package payload. + run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 + + - name: Run tests + timeout-minutes: 60 + run: >- + dotnet test Devolutions.Terminal.slnx -c Release --no-build -m:1 + -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + --logger "console;verbosity=normal" --logger trx + --results-directory artifacts/test-results/windows + --blame-hang --blame-hang-timeout 5min --blame-hang-dump-type none + + - name: Upload Windows test diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-test-diagnostics + path: artifacts/test-results/windows + if-no-files-found: ignore + retention-days: 7 - name: Test macOS legal notice layout run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1 @@ -212,7 +231,7 @@ jobs: if-no-files-found: error native-aot: - name: NativeAOT ${{ matrix.rid }} + name: NativeAOT desktop ${{ matrix.rid }} runs-on: windows-latest needs: release-metadata strategy: @@ -241,16 +260,28 @@ jobs: with: dotnet-version: 10.0.x - - name: Publish + - name: Publish NativeAOT desktop run: > dotnet publish src/Devolutions.Terminal/Devolutions.Terminal.csproj -c Release -r ${{ matrix.rid }} --self-contained + -p:EnablePowerShellIse=false + -p:PublishAot=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} -o artifacts/${{ matrix.rid }} - - name: Upload native executable + - name: Check opt-in managed Iseberg desktop + run: > + dotnet publish src/Devolutions.Terminal/Devolutions.Terminal.csproj + -c Release + -r ${{ matrix.rid }} + --self-contained + -p:EnablePowerShellIse=true + -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + -o artifacts/iseberg-validation/${{ matrix.rid }} + + - name: Upload NativeAOT desktop uses: actions/upload-artifact@v4 with: name: DevolutionsTerminal-${{ matrix.rid }} @@ -282,10 +313,13 @@ jobs: dotnet-version: 10.0.x - name: Restore - run: dotnet restore Devolutions.Terminal.slnx -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + run: dotnet restore Devolutions.Terminal.slnx -p:Configuration=Release -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Run managed tests - run: dotnet test Devolutions.Terminal.slnx -c Release --no-restore -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 + + - name: Test managed projects + run: dotnet test Devolutions.Terminal.slnx -c Release --no-restore -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Validate Linux scripts and metadata run: bash scripts/Test-LinuxPackagingMetadata.sh @@ -315,10 +349,13 @@ jobs: dotnet-version: 10.0.x - name: Restore - run: dotnet restore Devolutions.Terminal.slnx -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + run: dotnet restore Devolutions.Terminal.slnx -p:Configuration=Release -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Run managed tests - run: dotnet test Devolutions.Terminal.slnx -c Release --no-restore -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 + + - name: Test managed projects + run: dotnet test Devolutions.Terminal.slnx -c Release --no-restore -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Validate macOS scripts and metadata run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsPackagingMetadata.ps1 @@ -333,7 +370,7 @@ jobs: run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsNuGetSigning.ps1 macos-native-aot: - name: macOS NativeAOT ${{ matrix.rid }} + name: macOS NativeAOT desktop ${{ matrix.rid }} runs-on: macos-26 needs: - macos-managed @@ -364,19 +401,21 @@ jobs: with: dotnet-version: 10.0.x - - name: NativeAOT publish + - name: NativeAOT desktop publish run: > dotnet publish src/Devolutions.Terminal/Devolutions.Terminal.csproj -c Release -r ${{ matrix.rid }} --self-contained true + -p:EnablePowerShellIse=false + -p:PublishAot=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} -p:DebugSymbols=false -p:DebugType=None -p:NativeDebugSymbols=false -o artifacts/publish/${{ matrix.rid }} - - name: Upload macOS NativeAOT publish + - name: Upload macOS desktop publish uses: actions/upload-artifact@v4 with: name: DevolutionsTerminal-${{ matrix.rid }} @@ -649,7 +688,7 @@ jobs: throw "Runtime hash mismatch: expected ${{ matrix.runtime_sha256 }}, got $actualHash." } - - name: NativeAOT publish + - name: NativeAOT desktop publish shell: pwsh run: | $publishArgs = @( @@ -657,6 +696,8 @@ jobs: "-c", "Release", "-r", "${{ matrix.rid }}", "--self-contained", "true", + "-p:EnablePowerShellIse=false", + "-p:PublishAot=true", "-p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }}", "-p:DebugSymbols=false", "-p:DebugType=None", @@ -668,7 +709,7 @@ jobs: } dotnet publish @publishArgs - - name: Upload Linux NativeAOT publish + - name: Upload Linux desktop publish uses: actions/upload-artifact@v4 with: name: DevolutionsTerminal-${{ matrix.rid }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8817cd5..05fbabe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,14 +22,22 @@ as a tab on `/pty/{id}` (`ws` on this HTTP host, `wss` when the page is HTTPS). The browser never supplies a command line, and the host does not send one back. Elevated profiles are listed and refused. -Warnings are errors. Prefer small, reviewable changes that keep NativeAOT publish green. +Warnings are errors. Keep the default NativeAOT desktop publish green. +Iseberg is disabled by default; its managed developer build and regression suite +require `-p:EnablePowerShellIse=true`. See [Iseberg](docs/iseberg.md) for the +installed-engine boundary. -## NativeAOT +## Desktop publish ```powershell dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained ``` +The default desktop and release packages use NativeAOT and exclude Iseberg. +Add `-p:EnablePowerShellIse=true` only for an optional managed, untrimmed +Iseberg developer build. Use the same flag for restore/build/test/run commands +when working on Iseberg. The CLI remains NativeAOT in both variants. + Linux packages: ```bash diff --git a/Devolutions.Terminal.slnx b/Devolutions.Terminal.slnx index 502486a..1faefa3 100644 --- a/Devolutions.Terminal.slnx +++ b/Devolutions.Terminal.slnx @@ -16,6 +16,11 @@ + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index aa65286..d020f39 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,6 +18,7 @@ false true direct + false $(MSBuildThisFileDirectory)assets\icons\terminal.ico diff --git a/Directory.Packages.props b/Directory.Packages.props index 2911b07..cb7b136 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,8 @@ + + diff --git a/README.md b/README.md index 99ecfd1..6e2835d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Devolutions Terminal A cross-platform terminal emulator implemented in C# on **.NET 10**, published -with **NativeAOT**, and rendered with **Avalonia 12** / Skia: +as a self-contained **NativeAOT** desktop app, and rendered with **Avalonia 12** / Skia: - **ConPTY** on Windows and a real **forkpty** transport on Linux and macOS - Azure Cloud Shell for remote Azure sessions - selectable built-in or **Ghostty** VT engine - Windows Terminal-compatible `settings.json`, actions, keybindings, and `dt` CLI +- optional source-integrated **Iseberg** PowerShell ISE profiles in managed + developer builds only (disabled in default NativeAOT builds and release packages) This is the [Devolutions Terminal](https://github.com/Devolutions/devolutions-terminal) source tree. Projects, namespaces, and the GUI host use `Devolutions.Terminal.*`. @@ -29,23 +31,29 @@ Install [gsudo](https://gerardog.github.io/gsudo/) and ensure `gsudo.exe` is on Elevation is only available for local Windows sessions; UAC approval is still required, and cancelling it does not launch an unelevated shell. -## NativeAOT publish +## Desktop publish ```powershell dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained ``` -The native executable is written to +The executable is written to `src/Devolutions.Terminal/bin/Release/net10.0/win-x64/publish/Devolutions.Terminal.exe`. -macOS NativeAOT app bundles (Darwin only): +The default desktop and `dt` CLI are NativeAOT. Release packages exclude Iseberg. +An explicitly opted-in `-p:EnablePowerShellIse=true` developer build uses a +managed, untrimmed desktop and separately installed compatible PowerShell; see +[docs/iseberg.md](docs/iseberg.md). Saved Iseberg profiles remain intact, but +cannot launch in the default build. + +macOS app bundles (Darwin only): ```bash pwsh scripts/Build-MacOsPackage.ps1 osx-arm64 2026.3.0 artifacts/packages pwsh scripts/Test-MacOsPackage.ps1 osx-arm64 artifacts/packages/*.zip ``` -Linux x64 and ARM64 NativeAOT packages are built on Linux with: +Linux x64 and ARM64 packages are built on Linux with: ```bash scripts/Build-LinuxPackage.sh linux-x64 2026.3.0 artifacts/packages all @@ -113,7 +121,8 @@ Devolutions.Terminal.Connection ConPTY + Linux PTY + Azure Cloud Shell + brows Devolutions.Terminal.Settings Layered Windows Terminal-compatible JSON settings Devolutions.Terminal.Control Avalonia TermControl renderer Devolutions.Terminal.App Tabs, title bar, panes, actions, window behavior -Devolutions.Terminal NativeAOT executable and composition root +Devolutions.Terminal.Ise Imported Iseberg engine, editor and embedded workbench +Devolutions.Terminal Desktop executable and composition root Devolutions.Terminal.Browser Avalonia WebAssembly host (in-process dt-wasm shell) Devolutions.Terminal.Browser.Host Loopback server for profile tabs and a real PTY ``` diff --git a/assets/profile-icons/iseberg.scale-100.png b/assets/profile-icons/iseberg.scale-100.png new file mode 100644 index 0000000..ca923ff Binary files /dev/null and b/assets/profile-icons/iseberg.scale-100.png differ diff --git a/assets/profile-icons/iseberg.svg b/assets/profile-icons/iseberg.svg new file mode 100644 index 0000000..1a5dd61 --- /dev/null +++ b/assets/profile-icons/iseberg.svg @@ -0,0 +1,12 @@ + + Iseberg + An ice cube and PowerShell-style prompt contained within a square blue terminal icon. + + + + + + + + + diff --git a/docs/iseberg-parity.md b/docs/iseberg-parity.md new file mode 100644 index 0000000..f5c1b8e --- /dev/null +++ b/docs/iseberg-parity.md @@ -0,0 +1,270 @@ +# Original Windows PowerShell ISE feature comparison + +DT's source-integrated Iseberg in an explicitly enabled managed developer build +already covers the main edit/run/debug workflow. Default NativeAOT builds and +release packages disable Iseberg; saved profile settings are preserved. +The integration now matches additional portable ISE features, but this is **not a +drop-in replacement for the original `$psISE` object model, WPF add-ons, or +Windows PowerShell 5.1**. Several features exist in the imported workbench but +are deliberately disabled by DT; exact interaction and advanced endpoint +combinations still require comparison. + +This report compares the installed original ISE implementation, its documented +contracts, and the actual DT integration, not the standalone Iseberg application. +The initial audit identified the gaps below; subsequent licensed-source +adaptations implemented the portable subset recorded in this report. See +[integration and deployment](iseberg.md) for ownership and deployment details. + +## Scope, provenance, and evidence + +ILSpy CLI **11.1.0.9782** successfully exported the original managed launcher and +four UI assemblies as local projects: **1,213 C# files and 44 XAML files**. +The editor assembly contains substantial Visual Studio text infrastructure; +these counts are not counts of user features. The decompiled projects were not +built or imported into DT. The Windows PowerShell engine itself was not +decompiled. + +| Original component | File version | C# files | XAML files | Responsibility | +| --- | --- | --- | --- | --- | +| `powershell_ise.exe` | 10.0.26100.8875 | 7 | 0 | STA launcher, argument parsing handoff, existing-instance file routing | +| `Microsoft.PowerShell.GPowerShell` | 10.0.26100.8875 | 139 | 20 | ISE workbench, host, execution, debugging, object model, recovery | +| `Microsoft.PowerShell.Editor` | 10.0.26100.7309 | 899 | 8 | Text model, WPF editing, classification, IntelliSense infrastructure | +| `Microsoft.PowerShell.GraphicalHost` | 10.0.26100.1591 | 159 | 16 | Graphical command/help and related Windows UI | +| `Microsoft.PowerShell.ISECommon` | 10.0.26100.1 | 9 | 0 | Shared ISE command-line and instance communication support | + +The launcher is under +`C:\Windows\System32\WindowsPowerShell\v1.0`. The four DLLs are under +`C:\Windows\Microsoft.NET\assembly\GAC_MSIL`, with assembly identity +`3.0.0.0`, public key token `31bf3856ad364e35`. +The GUI reference engine reports **Windows PowerShell 5.1.26100.9444**; +assembly file versions are not the engine version. DT uses installed +**PowerShell 7.6.6**, Avalonia **12.1.1**, and AvaloniaEdit **12.0.0**. + +Local evidence lives in ignored +`artifacts\iseberg\original-ise-analysis`. Its `manifest.json` records exact +input paths, sizes, SHA-256 digests, tool version, and output counts. +Decompilation used `ilspycmd -p -o ` with the local .NET Framework +reference directory. All five exports completed successfully; an error-marker +scan found no decompilation failure markers. This does not prove every +decompiled expression is a perfect reconstruction. + +**Microsoft's implementation remains proprietary.** No Microsoft decompiled +source, reconstructed XAML, or original binaries belong in DT's source or +distribution. This report contains feature facts and API names, not copied +implementations. Future matching should use documented contracts and observable +behavior, implemented within the licensed Iseberg/Avalonia components. +Iseberg's MIT provenance and editor notices remain as documented in +[source provenance](iseberg.md#source-provenance). + +Official reference: +[About Windows PowerShell ISE](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_windows_powershell_ise?view=powershell-5.1). + +### Coverage labels + +| Label | Meaning | +| --- | --- | +| **G** | The stated workflow was exercised in both original ISE and DT. This is not exhaustive feature parity. | +| **S** | An implementation is present in the inspected source; the full original workflow was not newly GUI-tested. | +| **P** | A substantive subset exists, but behavior, API shape, or host wiring differs. | +| **D** | Implementation exists in imported Iseberg but DT deliberately disables it. | +| **M** | No corresponding implementation/wiring was found in the inspected integration. | +| **U** | Parity remains unverified; dependency capability alone is not proof. | +| **I** | Intentional architectural or runtime difference, not a missing portable feature. | + +### Source map + +Evidence codes in the matrices refer to these concrete implementation surfaces: + +| Code | DT / imported source | +| --- | --- | +| `L` | [DT tab creation and closing](../src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs), [tab host options](../src/Devolutions.Terminal.App/Views/PowerShellIseTab.cs) | +| `R` | `src\Devolutions.Terminal.Ise\Iseberg.Core\PowerShellSession*.cs`, `WorkbenchHost.cs` | +| `C` | [native console adapter](../src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs), [console control bridge](../src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs) | +| `E` | [editor control](../src/Devolutions.Terminal.Ise/Iseberg.Editor/PowerShellEditorControl.cs), [analysis](../src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs), [rendering](../src/Devolutions.Terminal.Ise/Iseberg/EditorRendering.cs) | +| `W` | [workbench editor, commands, help, and snippet actions](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.axaml.cs) | +| `F` | [persistence/recovery integration](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Persistence.cs), `Iseberg.Core\ScriptRecovery.cs`, `ScriptFile.cs` | +| `N` | [snippet service](../src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs), `Iseberg.Core\SnippetCatalog.cs` | +| `B` | [debugger UI](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Debugger.cs), `Iseberg.Core\PowerShellSession.Debugger.cs`, `DebuggerModels.cs` | +| `O` | [ISE scripting wrappers](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Scripting.cs) | +| `P` | [DT themes](../src/Devolutions.Terminal.Settings/IsebergThemes.cs), `Iseberg\OptionsWindow.axaml.cs`, `Iseberg.Core\UserSettings.cs` | +| `A` | [editor accessibility peer](../src/Devolutions.Terminal.Ise/Iseberg.Editor/AccessibleTextEditor.cs), installed AvaloniaEdit editing handlers | + +Original evidence paths below are relative to the local ignored analysis root. +Namespaces are retained in ILSpy's directory layout. In `GPowerShell`, internal +editor/host types below are under `Microsoft.Windows.PowerShell.Gui.Internal`; +the public scripting types are under `Microsoft.PowerShell.Host.ISE`. + +## 1. Launching, runspaces, execution, and native console + +Original evidence: `Launcher\Microsoft.Windows.PowerShell.GuiExe.Internal\GPowerShell.cs`, +`Microsoft.PowerShell.ISECommon\Microsoft.Windows.PowerShell.GuiExe.Internal\ISECommandLineParser.cs`, and +`GPowerShell\Microsoft.Windows.PowerShell.Gui.Internal` host, console, +profile, and execution classes. + +| Original feature | Coverage | DT behavior / remaining difference | Evidence | +| --- | --- | --- | --- | +| Open ISE as an editing/execution environment | I | A distinct `powershellIse` profile opens an embedded DT tab, not an external ISE process or ordinary shell profile. | L | +| STA interactive runspace, persistent state | G | Windows STA/reused thread; script and native console share the same engine. Variables survive successive commands. | R, C | +| F5 saved-script execution | G | Executes in the owning runspace; saved-file execution and reopen were compared. | R, W | +| F8 selection/current-line execution | G | Both supported; common current-line behavior was compared. | W, R | +| Stop, then execute another command | G | Cancellation/stop returns the session to usable input. | R, C, L | +| Pipeline, arithmetic, Unicode output | G | Common language/output cases matched, not every formatting or encoding scenario. | R, C | +| Host input such as `Read-Host` | G | Native console returns input to the owning engine. | R, C | +| Secure input, credentials, typed and choice prompts | S | Implemented host/dialog paths; each prompt form still needs dedicated comparison. | R | +| History, editable input, incomplete commands, multiline paste | S / P | Native editing and history retain complete multiline entries; bracketed paste is one undo edit. Exact original vertical-navigation semantics are not claimed. | C | +| Explicit Shift+Enter multiline editing | S | Inserts a newline without execution through the native input contract. | C | +| Console syntax coloring, popup IntelliSense, descriptions | S / P | Parser-colored VT input and a revision-guarded engine popup with descriptions are wired to the native console and its preferences. Not every original completion interaction is equivalent. | C, W | +| Console undo/redo/cut as a text editor | S | Input selection, cut/copy, bounded undo/redo and paste work through the console bridge; secure prompt text is excluded from public input/history APIs. | C, W | +| F1 / Show-Command for the active console command | S | Active native input exposes text/caret context to the workbench handlers. | C, W | +| Profiles and execution policy | I | Profile loading is opt-in and uses DT's Iseberg host profile, not Microsoft's ISE-specific profile. Execution remains unsandboxed and subject to the installed engine's policy. | R, L | +| Multiple local/remote ISE session tabs | D / I | Nested session creation is host-gated off; DT owns outer tabs and isolates their engines. | L, W | +| Enter/exit an interactive remote runspace | S | Pushed-runspace/remoting support exists. Hiding New Remote Tab does not remove `Enter-PSSession` support; endpoint/platform combinations remain unverified. | R | +| Original `-MTA`, `-NoProfile`, file arguments and existing-instance file routing | I / M | DT owns application startup/profile dispatch. No equivalent original ISE command-line/file-routing contract was found for this profile. | L | +| Windows PowerShell 5.1-specific workflows/modules | I | PowerShell 7 is not a compatibility clone of 5.1. Availability of legacy modules, Windows graphical cmdlets, COM/WPF tooling, and workflow syntax must be assessed separately. | R | + +Both original ISE and Iseberg have raw-host limitations. The original +`GPSHostRawUserInterface` itself rejects `ReadKey`, buffer reads, and several +cursor/window operations. Do not describe raw console limitations as a feature +that the original universally supports. Some original stored cursor/buffer/title +properties do differ from the rewrite. + +## 2. Script editing and completion + +Original evidence: `GPowerShell`'s `PowerShellTokenizationService.cs`, +`PowerShellErrorTagger.cs`, `PowerShellErrorTaggerProvider.cs`, `SmartIndent.cs`, +and the XML classification/outlining services; +`Microsoft.PowerShell.Editor\Microsoft.VisualStudio.Text.Operations.Implementation\EditorOperations.cs`. + +| Original feature | Coverage | DT behavior / remaining difference | Evidence | +| --- | --- | --- | --- | +| PowerShell token coloring, brace matching/navigation | S | Engine/parser-aware classification and brace navigation are implemented. | E, W | +| Parser diagnostics | S / P | Inline diagnostic rendering and diagnostic hover supplement the diagnostics text. Exact original marker presentation is not claimed. | E, W | +| Script IntelliSense and candidate acceptance | P | Engine completion, replacement spans, filtering, and popup acceptance exist. Original candidate selection/Tab acceptance and DT popup rendering were checked separately, not as a complete identical interaction suite. | E, W | +| Readable completion descriptions | S | DT's scoped description styling was GUI-verified. Original parent-window screenshots did not capture its separate WPF popup, so visual parity is not established. | E, P | +| Line numbers, wrap, font size, tabs/spaces | S | Existing editor/options support remains available. | E, W, P | +| Search/replace, navigation, undo/redo, cut/copy/paste | S | Provided by the editor/workbench; not every shortcut or search-option combination was compared. | E, W, A | +| Indentation | P | Basic indentation is inherited. Original `SmartIndent` copies the preceding nonempty line's indentation; it is not an AST-based formatter. Exact blank-line/selection behavior still needs comparison. | E, A | +| Case conversion and rectangular selection | S / U | AvaloniaEdit includes case-conversion commands and rectangular selection support. Their availability must not be classified as missing just because `$psISE.Editor` lacks wrappers; original bindings/UX were not tested. | A | +| Colored/rich clipboard copy | S | Copy/cut enrich the existing payload with escaped, palette-colored HTML, retaining plain text and selection metadata. RTF/original application-specific formats are not promised. | A, W | +| Curly-brace outlining | S | Multiline curly-brace folds are built and displayed. | E, W | +| `#region`, multiline comment/string outlining | S | Nested regions and multiline comment/string folds complement brace folds. | E | +| Dedicated XML / `.ps1xml` editing | S / P | Dedicated diagnostics, classification and folds are selected by document path; DTD/external resource resolution is prohibited. Not the original Visual Studio XML service. | E | +| Accessibility, IME, RTL editing | U | Accessible editor name/value support exists; the custom peer does not itself implement a text-range provider. Full screen-reader, IME, RTL, and native-console parity was not exercised. | A, C | + +## 3. Documents, recovery, and snippets + +Original evidence: `GPowerShell`'s `AutoSaveManager.cs`, +`Microsoft.PowerShell.Host.ISE\ISEFile*.cs`, and snippet classes. + +| Original feature | Coverage | DT behavior / remaining difference | Evidence | +| --- | --- | --- | --- | +| New/open/save/save-as; multiple script documents | G / S | Save As, close/reopen, and execution were compared; other file/encoding/conflict paths are source-backed. | W, F, O | +| Unsaved-close Save/Discard/Cancel | G | Cancel preserves the document and tab/window. DT's two-phase host close also preserves ownership across multiple tabs. | L, W | +| Recovery copies after crash / restart | S / P | Per-workspace recovery, exclusive storage leases and same-profile abandoned-workspace discovery are enabled. Forced cleanup retains dirty snapshots; approved closing removes them. | L, F, W | +| Restore layout/session | P | Stable DT workspace identity restores documents/options and offers dirty recovery into a fresh engine; runspace variables are not serialized. | L, F | +| Recent files and durable workbench geometry/options | S / P | DT-owned per-workspace persistence is enabled; it is not the original global ISE settings store. | L, F, P | +| Remote file editing/debug-source opening | S | Remote runspace ownership and open/read/save paths exist; dedicated remote end-to-end comparison remains necessary. | R, W, `WorkbenchControl.Remoting.cs` | +| Snippet XML, insertion, caret/indent expansion, export | S | Catalog, engine snippet service, expansion, and file transfer UI exist. | N, W | +| UI Create/Import using the host-owned snippet folder | S | Both save actions now use the selected engine's configured DT snippet directory, matching reload ownership. | N, W, L | + +Recovery uses DT workspace/profile identities and storage leases rather than +enabling standalone global persistence. Script text remains sensitive local +data, and recovery is not a backup or serialization of a live runspace. + +## 4. Debugging, inspection, and progress + +Original evidence: `GPowerShell` debugger/source-location classes, +`Microsoft.PowerShell.Host.ISE\ISEEditor.cs`, and +`Microsoft.Windows.PowerShell.Gui.Internal\ProgressSource.cs`. + +| Original feature | Coverage | DT behavior / remaining difference | Evidence | +| --- | --- | --- | --- | +| Breakpoint, Step Over, Continue | G | The common interactive debugging sequence was compared. | B, R, W | +| Step Into/Out, Break All, paused evaluation | S | Implemented; not a complete debugger command/edge-case comparison. | B, R | +| Line/command/variable breakpoints; conditions/actions; enable/delete | S | Existing engine/debugger models support these forms. | B, R | +| Variables, watches, call stack, object inspection | S | Available in the enabled debugger pane; watches and paged object inspection are useful rewrite features, not proof of original UX equivalence. | B | +| Exact paused statement extent / column breakpoint | P | Clean-source parser extents are used only when line/column starts match; otherwise highlighting falls back to the line. Original column breakpoint contracts remain unsupported. | B, E | +| Variable value on editor hover | S / P | Hover reads the selected paused-frame snapshot with source/session/frame/lifetime invalidation. Arbitrary expression evaluation and unsupported scoped/provider variables are not guessed. | B, E, W | +| Remote debugging / attached process combinations | U | Source support does not establish parity across WinRM/SSH endpoints, engine editions, or process attachment. | B, R | +| Nested/concurrent progress records | S / P | Source-isolated activity/parent hierarchy, multiple record text and completed-subtree cleanup are retained. The UI uses one selected-record bar rather than one bar per activity. | R, W | + +## 5. `$psISE` and add-on compatibility + +Original evidence: `GPowerShell\Microsoft.PowerShell.Host.ISE\ObjectModelRoot.cs`, +`PowerShellTab.cs`, `ISEFileCollection.cs`, `ISEEditor.cs`, `ISEOptions.cs`, +and their tab/add-on/snippet collection classes. DT evidence: `O`. + +| Original contract | Coverage | Compatibility difference | +| --- | --- | --- | +| Current tab/file and file/editor access | P | Useful wrappers exist, but they do not constitute the complete original contract. | +| Root `Options`, visible add-on tool properties | P / I | Portable validated Options are implemented; WPF tool controls remain unsupported. | +| `CurrentPowerShellTab.Files.SelectedFile` | S | File-collection selection alias is implemented with ownership checks. | +| Mutable tab `DisplayName` | S | Mutable display name retains the session's persistence identity. | +| Tab `ConsolePane`, status/expanded-script/commands/zoom surface | P | Portable status/zoom/Commands properties are implemented; this is not the entire original console/tool API. | +| `PowerShellTabs.Add/Remove`, `Invoke/InvokeSynchronous` | M / I | Wrappers explicitly throw unsupported exceptions. Any matching must preserve DT tab ownership, initialization, cancellation, and error handling. | +| File editing/saving and add-on menu actions/shortcuts | S | Existing wrappers provide useful portable functionality. | +| `Editor.CanGoToMatch`, `GoToMatch`, `ToggleOutliningExpansion` | S | Portable brace navigation and folding scripting members are wired. | +| Original snippet metadata shape | P | Provenance/fragment metadata is exposed; not every original collection mutator is reproduced. | +| Observable collections / original events | P | Collection/property notifications and disposal invalidation are added; original event shapes are not universally identical. | +| WPF horizontal/vertical add-on tools | I | Explicitly unsupported. Arbitrary Windows WPF controls cannot become cross-platform Avalonia controls through an API alias. | + +These selected documented members are implemented within the licensed rewrite, +without importing Microsoft code. Unsupported behavior throws explicitly. +Legacy profiles/add-ons are not drop-in compatible merely because Options, +selection aliases and events are available. + +## 6. Commands, help, themes, and settings + +Original evidence: `Microsoft.PowerShell.GraphicalHost` command/help UI and +`GPowerShell\Microsoft.PowerShell.Host.ISE\ISEOptions.cs`. + +| Original feature | Coverage | DT behavior / remaining difference | Evidence | +| --- | --- | --- | --- | +| Docked Commands/module browser | S | Host gate is enabled; initially hidden and user-controlled. | L, W | +| Show-Command parameter forms | S | Menu/dialog and engine host request paths still exist. Disabling the Commands dock does **not** disable Show-Command. | W, R | +| F1 local/online command help | S | Help viewers/online launching use the active editor or native-console context. | W | +| Update Help menu workflow | P | The PowerShell command remains available; the original dedicated menu workflow is not matched. | W, R | +| Original token/color preferences | P / I | Imported custom token/theme functionality exists, but DT disables color management when it owns the palette. | P, L | +| Persistent theme selection | S / I | Six original palette combinations and legacy Classic/Dark/Light/Follow DT are available in DT Profile appearance. The profile font wins; standalone theme-manager recommendations remain supported. | P, L | +| Other persistent Options | S / P | Profile-owned editor/recovery/completion settings are edited in DT Settings. Workspace geometry, view state and transient scripting changes remain scoped; this is not original global-user preference storage. | F, P | +| Native-console IntelliSense preferences | S | Automatic completion, timeout and Enter-acceptance preferences are wired to the native popup. | C, W, P | +| Localization | P | Imported workbench has an English resource catalog; this is not original ISE's localized resource coverage. | `Iseberg\Resources\Strings.resx`, `UiText.cs` | +| Print preview | S | Rewrite has an HTML/browser preview path. This is not treated as a missing original feature: original printing parity was not established. | F | + +## 7. Implemented scope and boundaries + +The portable implementation repairs snippet ownership, enables leased +workspace recovery/options, extends native input/completion/help, adds editor +and paused-debug details, extends a targeted `$psISE` subset, enables Commands +docking, and retains progress hierarchy. All six original built-in theme names +are available alongside DT's legacy choices. + +Exact original binary/window behavior, WPF plug-ins, cross-workbench scripting +operations, column breakpoints, advanced remoting combinations, full +accessibility equivalence and 5.1 engine compatibility remain outside this +verified portable subset. +There is no meaningful single "parity percentage" across these categories. + +## Validation boundary + +The final integration passes **1,570 tests, 0 failed, 9 skipped** across the +complete affected App, UI, Settings, Settings.Editor and Connection projects. +The nine skips are Unix PTY tests on this Windows host, not Iseberg tests. +The [final requirement evidence](iseberg.md#final-windows-integration-evidence) +records exact project totals, representative test names, GUI checks and +distribution gates. These results supersede the earlier 171/647-test scoped +baselines; they are not a claim that every original ISE API or workflow has +been reproduced. + +Original ISE Cua checks used a test-owned process and verified shared variables, +Read-Host, save/reopen/execute, actual completion selection/acceptance, and +canceling dirty close. Additional common run/stop/debug checks are recorded in +[the integration validation section](iseberg.md#windows-powershell-ise-compatibility-and-validation) +and ignored `artifacts\iseberg\validation-results.json`. + +The comparison informed the portable production/editor/console/scripting and +profile changes described above; no proprietary Microsoft implementation was +imported. User-owned DT processes and unsaved documents were preserved. +Linux/macOS packaging, installed Windows package certification, separate WPF +completion-popup visual parity, advanced debugger/remoting cases, and complete +accessibility/editor shortcut equivalence remain unverified. diff --git a/docs/iseberg.md b/docs/iseberg.md new file mode 100644 index 0000000..8fb8220 --- /dev/null +++ b/docs/iseberg.md @@ -0,0 +1,390 @@ +# Iseberg workbench profiles + +**Iseberg** is an embedded PowerShell ISE-style workbench, not a terminal +command line or a separate application. **It is disabled in default builds and +release packages to preserve DT's NativeAOT desktop.** The workflow below requires +an explicit managed developer build with `-p:EnablePowerShellIse=true`. +In **Settings**, select **Add a new +profile**, choose **PowerShell ISE (Iseberg)** under **Profile type**, configure +its name, starting directory and font, then select that +profile from the new-tab menu. Existing PowerShell profiles continue to launch +their normal PTY/ConPTY terminal sessions. + +The default profile logo is an original ice cube and terminal prompt contained +within a square terminal tile, with equal transparent margins on all four sides. +`assets/profile-icons/iseberg.svg` is its vector source; the derived high-resolution +PNG uses DT's existing profile-icon loader without adding an SVG runtime +dependency. New profiles, icon-less Iseberg profiles and the earlier default +ISE glyph use this logo; custom icons and ordinary PowerShell icons are retained. +`scripts/Build-IsebergIcon.ps1` regenerates the PNG with the managed SkiaSharp +assembly and matching native library from a DT build supplied as parameters. + +Each Iseberg tab owns a script editor, DT-native `TermControl` console, documents and +PowerShell runspace. F5 runs the script, F8 runs the selection/current line, and +the Stop button interrupts execution. Variables persist across executions in +that tab. The imported workbench also provides completion, script save/open, +debugging/breakpoints and snippets. **Tools / Options** opens the owning profile +in DT's existing Settings tab, rather than another preferences window. Nested +workbench session tabs and remote-session creation are hidden: DT owns tabs. + +The editor sits above the terminal in a resizable horizontal split. Editor +execution and typed console commands share the same in-process PowerShell +runspace, including variables, completion, debugger and host input. The lower +pane uses DT's VT renderer, scrollbar, clipboard and terminal interaction +settings, not Iseberg's original transcript editor or another `pwsh` process. +Document tabs and workbench chrome use compact desktop UI typography independently +of code font and zoom. The toolbar wraps in narrow DT windows, long document +titles truncate with a full-title tooltip, and the status/zoom bar stays compact. +The native console converts workbench DIP font sizes to DT's point-based renderer, +so editor and console glyphs use the same effective size rather than enlarging the +console by a second points-to-DIPs conversion. +Multiline paste is held until Enter; Shift+Enter inserts a newline and history +recall retains complete multiline commands. Native input supports selection, +cut/copy, bounded undo/redo, syntax coloring, and an engine-backed completion +popup with descriptions. Completion preferences apply to this console as well +as the script editor. Ctrl+C stops execution or cancels host input. + +**Native-program limitation:** this host is not a PTY. Redirected native output +is displayed, but interactive native programs and raw-host ReadKey/cursor/buffer +APIs are not fully supported. Use an ordinary terminal profile for interactive +native applications. + +## Color themes + +**Profile appearance** offers the six original +ISE built-in names: **Dark Console, Light Editor (default)**, **Light Console, +Dark Editor**, **Dark Console, Dark Editor**, **Light Console, Light Editor**, +**Monochrome Green**, and **Presentation**. The configured profile font is +honored even when an original palette is selected. Original font recommendations +remain available in the reusable workbench's standalone theme manager. The independently +adapted palettes preserve the editor/console combinations and representative +token/stream colors, not every original classification or pixel. + +**Classic ISE**, **Dark**, **Light**, and **Follow DT** remain supported for +existing profiles. Classic ISE is the default white editor/blue console. +Follow DT tracks DT's light/dark theme; other presets scope chrome, dialogs, +editor, completion descriptions, and terminal colors to the workbench without +changing DT's application theme. Profile changes apply to newly opened tabs. +Fonts, palettes, editor/recovery options and completion preferences are owned by +the profile JSON. **Profile advanced** contains ISE completion, outlining, help, +snippet and toolbar settings. These profile values override stale recovered +workspace preferences without replacing recovered documents, caret positions, +zoom or pane geometry. Transient view changes and scripting Options changes +remain local to the current workspace; custom palette editing/import remains +disabled in the DT host. + +## Installed PowerShell prerequisite + +Install **PowerShell 7.6.6 or newer within 7.6.x**, using .NET 10.0 and the +**same architecture as DT**. Make `pwsh` available on PATH, or set +`DT_ISEBERG_PSHOME` to its absolute installation directory before starting DT. +DT checks its version, runtime and architecture, and loads that installed engine +in-process. It does not start an external Iseberg desktop or use `pwsh` as the +workbench console process. A short noninteractive `pwsh -NoProfile` probe +discovers and validates the installation. + +This prerequisite is lazy: ordinary terminal profiles remain usable without +compatible installed PowerShell. Opening Iseberg without it produces an explicit +DT notification/command error. DT does **not** redistribute the PowerShell SDK +runtime/native dependency graph, which includes dependencies with separate +distribution terms. Compile-time SDK references exclude runtime, native and +content assets; publishing rejects accidental engine payloads. + +PowerShell executes with **DT's permissions and is not sandboxed**. Runspace +isolation is not process/security isolation; scripts can access the filesystem, +network and process-wide state. There is no unconditional execution-policy +bypass. **Load PowerShell profile scripts** is opt-in and defaults to off. +The current-host profile is `Devolutions.Terminal.Iseberg_profile.ps1`; normal +all-host profile scripts also run when enabled. Per-profile elevation, terminal +command lines, connection types and process environment overrides are unsupported. +Start DT itself elevated if an elevated workbench is necessary. + +## Persistence and lifecycle + +The explicit JSON discriminator is independent of the profile's display name: + +```json +{ + "guid": "{ff522154-bcf4-47ec-b8a7-6c0201e5e8f0}", + "name": "Iseberg", + "type": "powershellIse", + "commandline": "", + "startingDirectory": "%USERPROFILE%", + "ise": { + "loadProfiles": false, + "colorTheme": "Classic ISE", + "showLineNumbers": true, + "wordWrap": false, + "promptToSaveBeforeRun": true, + "autoSaveMinutes": 2, + "consoleIntelliSense": true, + "consoleCompletionOnEnter": true, + "scriptIntelliSense": true, + "scriptCompletionOnEnter": true, + "intelliSenseTimeoutSeconds": 3, + "showOutlining": true, + "warnDuplicateFiles": true, + "useLocalHelp": true, + "useDefaultSnippets": true, + "recentFileCount": 10, + "showToolbar": true + }, + "font": { "face": "Cascadia Code", "size": 12 } +} +``` + +The optional `ise` object extends the existing Windows Terminal-shaped profile; +identity, directory, icon, font and tab metadata keep their established fields. +Earlier dotted `ise.loadProfiles` / `ise.colorTheme` keys are still read and +updated when present; nested values take precedence. Unknown JSON properties, +including unknown `ise` members, survive editing. Older Iseberg profiles without +a color theme use Classic ISE. Unknown theme +names produce an explicit error instead of silently substituting a palette. +Profiles without `type` remain terminal profiles; their serialized shape does +not change. Unknown type strings are retained and rejected, not launched as +shells. Iseberg is unavailable in the browser and terminal-only hosts, where +saved ISE profiles are preserved but ISE creation/conversion is not offered. + +Changing an existing Terminal profile to ISE retains its identity and common +fields. Terminal-only command, elevation, connection and environment settings +move into inactive `ise.terminal` configuration and are restored if the type is +changed back. They are not applied to the ISE runspace; explicit incompatible +ISE launch overrides still fail. **Defaults** edits `profiles.defaults`, not the +first or previously selected named profile. + +DT persists profile and whole-tab layout descriptors. Version 2 layouts still +accept version 1 terminal layouts. Duplicate, reopen and layout restoration +create a **fresh runspace**; variables and live engine state are never serialized. +Workspace restoration can reopen saved documents and offer abandoned dirty +document recovery. New launches offer recovery only for the same profile and +never take over a live workspace. Iseberg tabs cannot split, zoom terminal panes +or tear off into other windows. Save scripts explicitly; recovery is not a +replacement for saving or backups. +Automatic same-profile recovery discovery requires a saved, nonempty profile +identity; anonymous profiles do not share recovery candidates. +Profile identity publication never replaces another owner, even across concurrent +processes. Linux/macOS workspace storage must support hard links for this atomic +publication; unsupported filesystems report a storage error. + +Closing a tab/window or quitting DT prompts to stop running commands and +save/discard dirty documents. Cancel leaves the tab alive. Bulk/application +closing prepares every workbench before disposing any, so a later cancellation +does not destroy an earlier approved tab. Forced host cleanup disposes runspaces, +timers and UI subscriptions. UI errors use DT notifications and tracing. + +DT owns the host window, application theme, fonts, tabs and settings location. +Standalone global settings and update checks remain disabled. Each workspace +uses `PowerShellIse/Workspaces//settings.json`, its workbench state +and recovery snapshots under DT's settings directory. An exclusive lease +prevents concurrent writers. The autosave preference controls recovery timing; +forced cleanup retains the latest dirty snapshot, while approved save/discard +closing removes it. Recovery contains script text and should be treated as +sensitive local data; it is not encrypted. + +Shared snippets live in `PowerShellIse/Snippets`; Create and Import write through +the selected engine's host-owned directory. Commands docking is available but +hidden initially. Progress retains source/activity/parent identities and shows +multiple records, rather than flattening every update into one activity. + +## Portable editing and scripting contracts + +The adapted editor adds region, multiline comment/string, and XML folds, +dedicated `.xml`/`.ps1xml` analysis, inline parser diagnostics with hover text, +and escaped, palette-colored HTML clipboard data alongside plain text. +XML analysis prohibits DTD/external resource resolution. Debugger hover reads +the selected paused frame's variable snapshot; it does not evaluate arbitrary +expressions. Paused statement highlighting uses a matching clean-source parser +extent, with a whole-line fallback when that extent cannot be established. + +The portable `$psISE` layer includes Options/preferences, file-collection +selection, mutable tab display names, status/zoom/Commands properties, +brace/outlining editor methods, snippet provenance metadata, and observable +notifications. Preference validation, ownership, UI-thread dispatch and +persistence errors are explicit. This is a supported subset, not the complete +Microsoft object model: WPF add-on controls, cross-workbench tab creation/removal +and original Invoke/InvokeSynchronous contracts remain unsupported. + +## Build and distribution + +The default desktop and release packages use **NativeAOT with Iseberg disabled**. +The full PowerShell parser/runspace/debugger requires dynamic code, so the +current in-process integration cannot run in NativeAOT. Iseberg remains an +explicit developer opt-in; its desktop publish is **self-contained, single-file, +managed and untrimmed**. The `dt` CLI remains NativeAOT in both variants. + +```powershell +dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained +dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained ` + -p:EnablePowerShellIse=true -o artifacts/iseberg-dev/win-x64 +dotnet run --project src/Devolutions.Terminal -p:EnablePowerShellIse=true +dotnet test Devolutions.Terminal.slnx -p:EnablePowerShellIse=true +``` + +The first command produces the default NativeAOT desktop. It retains saved +Iseberg profiles but reports the missing feature rather than silently opening +PowerShell terminals. Its settings editor does not offer new Iseberg profiles. +The second command produces an opt-in managed developer publish, not a default +release package payload. Use the opt-in flag consistently for restore, build +and test commands, and restore with `-p:Configuration=Release` before a Release +build with `--no-restore`. Ordinary managed builds remain framework-dependent; +self-contained deployment applies at publish time. Engine/workbench tests are +excluded when the feature is off. +CI still builds and tests this opt-in variant separately. Publishing an +Iseberg-enabled build with trimming or NativeAOT explicitly enabled fails before +compiling project dependencies. +Windows CI runs test projects sequentially (`-m:1`) to avoid concurrent test-host +startup starving short broker deadlines. It retains TRX results and per-test +hang diagnostics; the complete suite has a separate, longer time budget for the +slower hosted Windows runner. + +Native libraries/helpers and legal notices remain loose for package signing and +license checks. macOS packaging defaults to NativeAOT; an explicitly supplied +managed publish must be self-contained and single-file, not an arbitrary loose +managed application layout. +Keep all `THIRD-PARTY-NOTICES*.txt` files in distributions. + +The restored default was validated with a fresh `win-x64` Release publish +without an `EnablePowerShellIse` override. Both desktop and CLI are native PE +executables without CLR headers; the output contains no Iseberg, PowerShell +engine or CoreCLR payload, and `dt --help` exits zero. Default App, UI and +Settings.Editor suites pass 213, 14 and 64 cases respectively. Explicit opt-in +App/UI runs still pass all 533/664 cases. Platform script syntax and workflow +YAML were checked on Windows; actual Linux/macOS publishing and packaging +remain platform-CI validation responsibilities. + +For engine tests on a machine without the matching installation, +`scripts/Install-IsebergTestPowerShell.ps1` downloads a pinned, SHA-256-verified +portable runtime under `artifacts/test-powershell`. It does not change the system +installation or PATH. Dot-source it to set `DT_ISEBERG_PSHOME` in the caller; +GitHub Actions sets that variable for subsequent steps automatically. +**Never package this test-runtime directory with DT.** + +## Windows PowerShell ISE compatibility and validation + +Microsoft's original Windows PowerShell ISE uses Windows PowerShell 5.1 and the +`Windows PowerShell ISE Host`; DT's workbench uses installed PowerShell 7.6.6 and +the `Iseberg` host. Compare common PowerShell behavior, not engine-version or +host-object identity. Windows PowerShell-only modules and scripts may need the +usual changes for PowerShell 7. + +The [part-by-part original ISE comparison](iseberg-parity.md) records a local +ILSpy audit of the original launcher and four UI assemblies. It distinguishes +verified workflows, source-backed capabilities, DT-disabled features, remaining +compatibility gaps, and runtime differences; it does not claim full ISE parity +or import Microsoft's implementation. + +Actual Windows GUI comparison verified matching saved-script arithmetic, +pipeline and Unicode results, F8 current-line execution, Stop followed by another +execution, and breakpoint/Step Over/Continue behavior. Cua Driver also verified +both directions of editor/console variable sharing, Read-Host input, native +Save As and disk reopen/execute, and canceling an unsaved window close without +losing its document in original ISE. These workflows also passed in DT. +Original ISE completion selection/acceptance was verified separately; DT +verified its four theme presets and readable, theme-scoped completion +descriptions. The managed +single-file distribution executed an editor script using installed PowerShell; +an ordinary profile separately executed in a ConPTY-backed `ConsoleHost`. + +Original ISE's WPF text providers ignored background ValuePattern writes, but +Cua Driver's snapshot-targeted native background keys delivered editor/console +input successfully without foreground escalation. Completion was verified from +the actual candidate selection and resulting editor text; the parent-window +capture did not include the separate WPF completion popup, so this does not +claim visual-description parity or full UI parity. Actual +Linux/macOS packaging and installed Windows package certification still require +their respective platform/release environments. + +### Final Windows integration evidence + +The Iseberg-enabled source passed **1,570 tests, 0 failed, 9 skipped** across the complete +affected projects. The nine skips are the existing Unix PTY cases, which require +Linux/macOS; no Iseberg case is skipped. + +| Test project | Passed | Failed | Skipped | +| --- | ---: | ---: | ---: | +| Devolutions.Terminal.App.Tests | 533 | 0 | 0 | +| Devolutions.Terminal.UI.Tests | 664 | 0 | 0 | +| Devolutions.Terminal.Settings.Tests | 190 | 0 | 0 | +| Devolutions.Terminal.Settings.Editor.Tests | 64 | 0 | 0 | +| Devolutions.Terminal.Connection.Tests | 119 | 0 | 9 | + +These are successful fresh Debug `dotnet test` runs with Iseberg enabled, not +sums of overlapping filtered runs. With the feature now disabled by default, +repeat them with `-p:EnablePowerShellIse=true`. +Each project was run with `-p:SkipNativeRestore=true`, +`-p:UseArtifactsOutput=true` and the same absolute `ArtifactsPath`, sequentially +to avoid shared build-output collisions. Exact results are retained locally in +ignored `artifacts\iseberg\portable-final-results`: `final-App.trx`, +`final-UI.trx`, `final-Settings.trx`, `final-Settings.Editor.trx` and +`final-Connection.trx`. Older filtered runs in that directory are historical, +not part of these totals. + +| Requirement | Representative passing evidence | +| --- | --- | +| Standard profile creation, reversible conversion, identity and terminal compatibility | `IseProfileHarmonizationTests.StandardProfileTypeConversionPreservesTerminalConfigurationAcrossSaveReloadAndSwitchBack`; `NormalAddProfileOffersIseTypeAndSidebarUsesLogoWithoutSeparateCreationButton` | +| Defaults, canonical nested JSON, legacy aliases, unknown fields and option bounds | `DefaultsEditsActualDefaultsAndProfileSelectionFollowsIntoAppearance`; `NestedOptionsOverrideLegacyKeysAndAdvancedEditsPreserveUnknownFields`; `NumericOptionBoundariesSurviveSaveReload` | +| Shared editor/native-console variables and independent outer tabs | `IsebergTerminalBridgeTests.ScriptAndNativeConsoleSharePersistentRunspace`; `MainWindowPowerShellIseTests.DuplicateAndReopenRetainIseAndUseIndependentWorkbenches` | +| Persistent profile-owned preferences and DT Options routing | `IseProfilePreferenceTests.ProfilePreferencesOverrideStaleWorkspaceAppearanceAndOptionsRoutesToHostWithoutAnotherDialog` | +| Original palettes, Follow DT behavior and completion-description contrast | `IsebergTerminalBridgeTests.OriginalBuiltInThemesRenderNativeTerminalColorsAndRemainFixedAcrossHostChanges`; `OriginalBuiltInThemesRenderCompletionAndDescriptionWithinScriptScope`; `NativeConsolePaletteUsesSelectedThemeAndTracksOnlyFollowDt` | +| Ordered recovery, caret/selection/session restoration and storage failures | `PowerShellIseTabTests.RecoverRestoresSavedCaretOffsetsForEveryRecoveredDocument`; `RecoverRestoresSavedDocumentSelection`; `RecoveryStorageFailureKeepsPublishedDraftAndOriginalSnapshotForRetry`; `PortableIseWorkbenchTests.RecoverRoutesSameNamedDocumentsToSavedSessionsAndRestoresActiveSession` | +| Cancelable actual close routes, live ownership and deterministic cleanup | `MainWindowPowerShellIseTests.ActualCloseEntryPointsCancelAndRetainPreferencesRecoveryLeasesSelectionAndNativeRuntimes`; `PowerShellIseTabTests.FailedHostedInitializationReleasesLeaseImmediatelyWithoutWaitingForDispose`; `PortableIseWorkbenchTests.DisposalDetachesRetainedScriptingModelsWithoutExplicitSessionRemoval` | +| Native continuation and atomic invalid-completion rejection | `IsebergNativeContinuationTests.IncompleteEnterContinuesAndCompletedDraftExecutesOnce`; `BracketedLeadingLfAfterEnterIsNotMistakenForCrLfContinuation`; `IsebergTerminalBridgeTests.NativeCompletionRejectsOverflowAndEndSpanAtomicallyPreservingBothEditStacks` | +| Rich copy, contextual local help and retained progress hierarchy | `PortableIseWorkbenchTests.ActualHeadlessClipboardCopyCarriesOnlySelectedPlainTextAndRichHtml`; `F1LocalHelpUsesEnclosingCommandAcrossCommandArgumentNestedAndPipelineContexts`; `PortableIseProgressContinuationTests.WorkbenchCloseAndDisposalClearProgressAndRejectAlreadyQueuedDelivery` | +| Licensed source, host boundaries and deployment gates | Source provenance below; desktop `ValidatePowerShellIsePublish` and `ValidateInstalledPowerShellBoundary` targets; successful managed/NativeAOT publications | + +Cua Driver verified normal Add profile/type selection, saved canonical JSON, +Defaults editing and Tools/Options routing in a test-owned application. The final +managed single-file publish executed an editor script and emitted +`DT-FINAL-PUBLISHED:42` in its embedded DT-native console using installed +PowerShell 7.6.6. A separate ordinary PowerShell profile emitted +`DT-FINAL-NORMAL:42` in the same final binary. The test-owned application then +closed normally through its unsaved-script prompt; only the explicitly created +test script was discarded. User-owned applications and unsaved documents were +not used for these checks. + +The final desktop Debug build completes with zero warnings/errors. Both the +managed Iseberg desktop with NativeAOT CLI and terminal-only NativeAOT desktop +publish successfully for `win-x64`; both CLI `--help` processes exit zero. +Explicit Iseberg-enabled AOT/trimming requests fail with the intended +dynamic-code diagnostic. Required MIT/editor notices remain in the managed +publish, and the installed PowerShell runtime is not redistributed. + +Failure-path corrections retain recovery order/selection/caret, avoid publishing +released ownership during cancelable close preparation, unwind failed startup +before a recovery draft is published, and keep a partially recovered draft and +its lease until explicit cleanup when storage fails. Clipboard enrichment uses +AvaloniaEdit 12's supported `TextCopied` event rather than its inactive legacy +copying event. Collection moves/resets preserve active wrappers and detach +removed sessions; disposed controls reject queued progress delivery. + +## Source provenance + +The local module is `src/Devolutions.Terminal.Ise`, imported from +[mamoreau-devolutions/iseberg](https://github.com/mamoreau-devolutions/iseberg) +at revision **`d2e723f4f8ed4e08db28a89f09a783e40aed001c`**: + +| Local project | Imported responsibility | +| --- | --- | +| Iseberg.Core | PowerShell engine/host, execution, completion, debugger, files and settings models | +| Iseberg.Editor | Engine-independent AvaloniaEdit editor, accessibility, markers and editing commands | +| Iseberg | Embeddable WorkbenchControl, dialogs, models and scoped resources | + +Standalone App, MainWindow, Program, update/runtime startup and standalone +application themes were not adopted. Local adaptations add DT starting +directories and host profile names, two-phase cancelable closing, DIP font +sizes, scoped theme presets and completion popup resources, the DT VT console +adapter, hidden-session action gates, and the +installed-engine resolver in DT's connections layer. DT remains on Avalonia +12.1.1; AvaloniaEdit is 12.0.0 and the compile-time PowerShell SDK is 7.6.6. + +Iseberg is **MIT**, copyright (c) 2026 Adam Driscoll. Its license is retained in +[`docs/licenses/iseberg.txt`](licenses/iseberg.txt) and emitted as +`THIRD-PARTY-NOTICES-ISEBERG.txt`. The copied upstream +[editor notices](licenses/iseberg-editor.md) describe upstream's editor +distribution and are emitted as `THIRD-PARTY-NOTICES-ISEBERG-EDITOR.txt`. +DT also includes +the full AvaloniaEdit/AvalonEdit MIT notices, including AvaloniaUI, AvalonEdit +Contributors and AlphaSierraPapa/SharpDevelop attribution, in +[`docs/licenses/avaloniaedit.txt`](licenses/avaloniaedit.txt), emitted as +`THIRD-PARTY-NOTICES-AVALONIAEDIT.txt`. +Other DT dependency notices and their own licenses still apply. diff --git a/docs/licenses/avaloniaedit.txt b/docs/licenses/avaloniaedit.txt new file mode 100644 index 0000000..3edccf3 --- /dev/null +++ b/docs/licenses/avaloniaedit.txt @@ -0,0 +1,51 @@ +AvaloniaEdit 12.0.0 +https://github.com/AvaloniaUI/AvaloniaEdit/tree/12.0.0 + +MIT License + +Copyright (c) 2017 Eli Arbel +NuGet package copyright: Copyright 2017-2026 (c) The AvaloniaUI Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +AvaloniaEdit is derived from AvalonEdit. +https://github.com/icsharpcode/AvalonEdit + +MIT License + +Copyright (c) AvalonEdit Contributors +Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/iseberg-editor.md b/docs/licenses/iseberg-editor.md new file mode 100644 index 0000000..56a4683 --- /dev/null +++ b/docs/licenses/iseberg-editor.md @@ -0,0 +1,17 @@ +# Editor distribution notices + +Iseberg is copyright (c) 2026 Adam Driscoll, licensed under MIT. The complete `LICENSE` is included in the editor nupkg. The editor module owns the shared accessibility peer, editing/analysis lifetime and completion presentation; the runtime-owning workbench uses it rather than compiling a parallel editor. + +Direct packages `Avalonia` **12.1.3** and `Avalonia.AvaloniaEdit` **12.0.0** declare MIT in official NuGet metadata. The producer lock also includes `Avalonia.BuildServices` 11.3.2, `Avalonia.Remote.Protocol` 12.1.3 and `MicroCom.Runtime` 0.11.6, each declaring MIT. Preserve upstream copyright/license and bundled third-party notices when distributing their assemblies. + +AvaloniaEdit is a port of AvalonEdit. PowerShell lexical highlighting loads AvaloniaEdit's bundled `Highlighting/Resources/PowerShell.xshd` at runtime under that library's license, without copying/relicensing the implementation. Each control has its own palette; no global definition is modified. Shared scoped styles reference the library's existing base templates rather than copying them. + +The editor **does not depend on or redistribute System.Management.Automation, Microsoft.PowerShell.SDK, PowerShell modules, Microsoft.Management.Infrastructure or PowerShell-native binaries**. It has no built-in PowerShell parser. Host-owned analysis and completion providers are opt-in; their licenses and behavior are not covered by Iseberg's MIT license. + +Headless test consumers explicitly reference `System.Management.Automation` 7.6.6 only to observe a host-owned default runspace and the creation counter. They never use that parser or open that runspace. That **test-only** graph includes `Microsoft.Management.Infrastructure.Runtime.Win` 3.0.0, whose `LICENSE.txt` has Microsoft-specific PowerShell-use and redistribution restrictions rather than MIT. Do not redistribute that test output as an editor distribution. A host choosing its own PowerShell dependency must independently review those terms; the producer removed that dependency rather than promising unsupported static parsing or permissive redistribution. + +The native desktop example supplies Avalonia Desktop/Fluent/Skia/HarfBuzz and their runtime assets itself. Review its full lock file, the restored `.nuspec` license fields, included third-party notices and final RID output. Some native assets carry BSD/Apache or bundled licenses; Iseberg's MIT license does not relicense them. + +The reviewed native example graph declares MIT expressions except `Avalonia.Angle.Windows.Natives` 2.1.27548.20260419, which supplies a BSD-style `LICENSE` requiring preservation of notices in source/binary distributions and restricting endorsement. Skia/HarfBuzz packages declare MIT at package level but bundle upstream third-party notices as well; preserve those with the host's native assets. + +The tested target is net10.0 with the exact direct versions above. No native-free desktop guarantee, macOS/Linux native or screen-reader certification, trim/AOT guarantee or script sandbox is implied. Executing captured text is solely a deliberate host operation, outside this package. diff --git a/docs/licenses/iseberg.txt b/docs/licenses/iseberg.txt new file mode 100644 index 0000000..0d9cd54 --- /dev/null +++ b/docs/licenses/iseberg.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Adam Driscoll + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/macos.md b/docs/macos.md index 1da94eb..535da0e 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -1,7 +1,7 @@ # macOS support macOS is a first-class host for the managed app, Unix PTY transport, built-in -and Ghostty engines, and NativeAOT `.app` packaging (including signed, +and Ghostty engines, and self-contained `.app` packaging (including signed, notarized `.dmg` release artifacts). Global hotkeys, default terminal registration, and Homebrew remain out of scope. @@ -28,7 +28,7 @@ registration, and Homebrew remain out of scope. - Notifications through `osascript` `display notification` - `dterm:` URL scheme declared in `macos/Info.plist` (argv and Apple Event protocol activation) -- NativeAOT `.app` + zip packaging on Darwin +- NativeAOT desktop `.app` + zip packaging on Darwin (Iseberg disabled) - Signed, notarized `.dmg` release artifacts via `scripts/Release-MacOsPackage.ps1` (CI only; ad-hoc unsigned zip/dmg locally or without Apple signing secrets) @@ -53,7 +53,8 @@ pwsh scripts/Test-MacOsPackage.ps1 osx-arm64 artifacts/packages/*.zip pwsh scripts/Test-MacOsRuntime.ps1 artifacts/packages ``` -`Build-MacOsPackage.ps1` publishes NativeAOT unless `MACOS_PUBLISH_DIR` is set, +`Build-MacOsPackage.ps1` publishes the NativeAOT desktop with Iseberg disabled +unless `MACOS_PUBLISH_DIR` supplies an existing publish, stages `Devolutions Terminal.app` with `macos/Info.plist`, generates `DevolutionsTerminal.icns` from the original WT Distro vector artwork in `macos/DevolutionsTerminal.svg`. Its librsvg-rendered 1024px transparent master, @@ -67,7 +68,7 @@ Published `THIRD-PARTY-NOTICES*.txt` files (including transitive dependency noti `Test-MacOsLegalNotices.ps1` checks this layout without requiring Apple signing credentials. Unix publishes exclude Windows-only ConPTY `OpenConsole.exe` hosts from the final publish list, including files supplied by RID-less project references. Package validation and signing check every file under `Contents/MacOS`, not just top-level files. -Ordinary CI runs the release signing script with an ad-hoc identity on both actual NativeAOT app bundles, so nested-code failures are caught before a credentialed release. +Ordinary CI runs the release signing script with an ad-hoc identity on both actual app bundles, so nested-code failures are caught before a credentialed release. `Stage-MacOsNuGetPayload.ps1` creates the existing flat NuGet native layout from that app and signs the copied code as standalone executables/libraries. Signed releases use the same Developer ID identity, Hardened Runtime, application entitlements, and secure timestamps; unsigned CI uses an ad-hoc identity. Both actual macOS NuGet packages are extracted and checked by `Test-MacOsNuGetPayload.ps1` on macOS before release publication. diff --git a/docs/release.md b/docs/release.md index 0a549f8..ed89fd3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -32,9 +32,10 @@ CI workflows: - `build-ghostty.yml` — compile `libghostty-vt` for every RID and upload artifacts (optional cache; not required to develop). -- `build-terminal.yml` — restore natives from source, test, NativeAOT, Linux +- `build-terminal.yml` — restore natives from source, test, NativeAOT desktop, Linux packages, macOS `.app`/zip, MSIX, the `Devolutions.Terminal.Control` - NuGet package, and the browser WASM host. + NuGet package, and the browser WASM host. Opt-in managed Iseberg builds are + validated separately and are not release package payloads. ## Developer build @@ -45,10 +46,19 @@ dotnet test Devolutions.Terminal.slnx -c Release dotnet run --project src/Devolutions.Terminal ``` -Warnings are errors for production projects. The trim and NativeAOT analyzers -run continuously rather than only during release packaging. +Warnings are errors for production projects. Trim and NativeAOT analyzers run +continuously on compatible projects. The imported PowerShell workbench requires +dynamic code and is deliberately excluded from those analyzers. -## NativeAOT +## Desktop distribution + +Default publishes and release packages are self-contained NativeAOT and exclude +Iseberg (`EnablePowerShellIse=false`). CI uploads these NativeAOT publishes for +Windows, Linux and macOS packaging; the separately validated managed Iseberg +publish is not uploaded as a release payload. The `dt` CLI remains NativeAOT. +Iseberg requires an explicit `-p:EnablePowerShellIse=true` developer build, +which is managed and untrimmed and uses separately installed PowerShell. +See [iseberg.md](iseberg.md) for prerequisites, legal notices and guards. ```powershell dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained ` @@ -68,7 +78,7 @@ MSIX staging additionally builds `dt-shell-integration.exe` and `Devolutions.Terminal.ShellExt.dll` for the package architecture with the installed MSVC/Windows SDK toolchain. -macOS NativeAOT app bundles (Darwin only): +macOS app bundles (Darwin only): ```bash dotnet publish src/Devolutions.Terminal -c Release -r osx-arm64 --self-contained \ @@ -95,7 +105,7 @@ scripts/Build-LinuxPackage.sh linux-x64 2026.3.0 artifacts/packages all scripts/Build-LinuxPackage.sh linux-arm64 2026.3.0 artifacts/packages all ``` -These commands publish NativeAOT executables and create reproducible tar, DEB, +These commands publish the default self-contained desktop and create reproducible tar, DEB, RPM, and AppImage artifacts plus a SHA-256 manifest. Every format is staged from one canonical filesystem root and includes `Devolutions.Terminal`, `dt`, `dt-pty-host`, Ghostty, Skia, HarfBuzz, licenses, freedesktop metadata, icons, diff --git a/scripts/Build-IsebergIcon.ps1 b/scripts/Build-IsebergIcon.ps1 new file mode 100644 index 0000000..049a2ae --- /dev/null +++ b/scripts/Build-IsebergIcon.ps1 @@ -0,0 +1,66 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $SkiaSharpAssemblyPath, + [Parameter(Mandatory)] + [string] $NativeLibraryPath +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$source = Join-Path $root 'assets\profile-icons\iseberg.svg' +$output = Join-Path $root 'assets\profile-icons\iseberg.scale-100.png' + +Add-Type -Path (Resolve-Path -LiteralPath $SkiaSharpAssemblyPath).Path +$nativeHandle = [System.Runtime.InteropServices.NativeLibrary]::Load( + (Resolve-Path -LiteralPath $NativeLibraryPath).Path) +try { + $readerSettings = [System.Xml.XmlReaderSettings]::new() + $readerSettings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit + $readerSettings.XmlResolver = $null + $reader = [System.Xml.XmlReader]::Create($source, $readerSettings) + try { + $svg = [System.Xml.Linq.XDocument]::Load($reader) + } finally { + $reader.Dispose() + } + $info = [SkiaSharp.SKImageInfo]::new(128, 128) + $surface = [SkiaSharp.SKSurface]::Create($info) + try { + $surface.Canvas.Clear([SkiaSharp.SKColors]::Transparent) + $surface.Canvas.Scale(4) + foreach ($element in $svg.Root.Elements()) { + if ($element.Name.LocalName -in @('title', 'desc')) { continue } + if ($element.Name.LocalName -ne 'path') { + throw "Unsupported icon element: $($element.Name.LocalName)" + } + $path = [SkiaSharp.SKPath]::ParseSvgPathData($element.Attribute('d').Value) + if ($null -eq $path) { throw 'Invalid SVG path.' } + $paint = [SkiaSharp.SKPaint]::new() + try { + $paint.IsAntialias = $true + $paint.Color = [SkiaSharp.SKColor]::Parse($element.Attribute('fill').Value) + $surface.Canvas.DrawPath($path, $paint) + } finally { + $paint.Dispose() + $path.Dispose() + } + } + $image = $surface.Snapshot() + try { + $data = $image.Encode([SkiaSharp.SKEncodedImageFormat]::Png, 100) + try { + [System.IO.File]::WriteAllBytes($output, $data.ToArray()) + } finally { + $data.Dispose() + } + } finally { + $image.Dispose() + } + } finally { + $surface.Dispose() + } +} finally { + [System.Runtime.InteropServices.NativeLibrary]::Free($nativeHandle) +} +Write-Output $output diff --git a/scripts/Build-LinuxPackage.sh b/scripts/Build-LinuxPackage.sh index a304ff3..c5fca4a 100644 --- a/scripts/Build-LinuxPackage.sh +++ b/scripts/Build-LinuxPackage.sh @@ -98,6 +98,8 @@ else -c Release -r "$rid" --self-contained true + -p:EnablePowerShellIse=false + -p:PublishAot=true -o "$publish_dir" -p:VersionPrefix="$version" -p:DebugSymbols=false @@ -169,7 +171,7 @@ Section: utils Priority: optional Homepage: $HOMEPAGE Description: $SUMMARY - Cross-platform Avalonia terminal emulator published with .NET NativeAOT. + Cross-platform Avalonia terminal emulator with an embedded Iseberg PowerShell workbench. EOF ( cd "$package_root" @@ -229,7 +231,7 @@ Requires: $RUNTIME_DEPENDENCIES_RPM AutoReqProv: no %description -Cross-platform Avalonia terminal emulator published with .NET NativeAOT. +Cross-platform Avalonia terminal emulator with an embedded Iseberg PowerShell workbench. %prep diff --git a/scripts/Build-MacOsPackage.ps1 b/scripts/Build-MacOsPackage.ps1 index 26039fd..bea589f 100644 --- a/scripts/Build-MacOsPackage.ps1 +++ b/scripts/Build-MacOsPackage.ps1 @@ -72,6 +72,8 @@ try { '-c', 'Release', '-r', $Rid, '--self-contained', 'true', + '-p:EnablePowerShellIse=false', + '-p:PublishAot=true', '-o', $publishDir, "-p:VersionPrefix=$Version", '-p:DebugSymbols=false', diff --git a/scripts/Install-IsebergTestPowerShell.ps1 b/scripts/Install-IsebergTestPowerShell.ps1 new file mode 100644 index 0000000..5b1b21a --- /dev/null +++ b/scripts/Install-IsebergTestPowerShell.ps1 @@ -0,0 +1,44 @@ +#Requires -Version 7 +[CmdletBinding()] +param([string]$DestinationRoot = (Join-Path $PSScriptRoot '..\artifacts\test-powershell')) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true +$version = '7.6.6' +$architecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() +$platform = if ($IsWindows) { 'win' } elseif ($IsMacOS) { 'osx' } elseif ($IsLinux) { 'linux' } else { throw 'Unsupported test platform.' } +$rid = "$platform-$architecture" +$hashes = @{ + 'win-x64' = '02fe458be20493fbdf43f61ea20610b811ee6c738ab1676c61b9cfcd1a33c860' + 'win-arm64' = 'bbde9dda31d148415eccb5fbe1638e6400a144187b006e5b3fd8ec2f39d781be' + 'linux-x64' = 'ddbc4a2d113bbd46d283cfedcbcd117a70caefd7673f41f2b4e0000badf103bc' + 'linux-arm64' = '924829e54c983648f6f1419a2dc7f9433c861b2fb5bd57736ff096c24f133729' + 'osx-x64' = 'e325ed9f666894eb39a5ea52800b602da2fb4242bbe9747ceddb39cdc66de805' + 'osx-arm64' = '6df833d094ebac1c1a74340d7b3437f4aaf5e03ce640484a1c4359f3ce8b3db1' +} +if (-not $hashes.ContainsKey($rid)) { throw "No pinned PowerShell test runtime for $rid." } +$root = [IO.Path]::GetFullPath($DestinationRoot) +$engineDirectory = Join-Path $root "$version-$rid" +$executable = Join-Path $engineDirectory $(if ($IsWindows) { 'pwsh.exe' } else { 'pwsh' }) +if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + New-Item -ItemType Directory -Path $engineDirectory -Force | Out-Null + $asset = if ($IsWindows) { "PowerShell-$version-$rid.zip" } else { "powershell-$version-$rid.tar.gz" } + $archive = Join-Path $root $asset + Invoke-WebRequest "https://github.com/PowerShell/PowerShell/releases/download/v$version/$asset" -OutFile $archive + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $hashes[$rid]) { + throw "PowerShell test runtime checksum mismatch: $archive" + } + if ($IsWindows) { + Expand-Archive -LiteralPath $archive -DestinationPath $engineDirectory -Force + } else { + & tar -xzf $archive -C $engineDirectory + & chmod +x $executable + } + Remove-Item -LiteralPath $archive +} +$actual = & $executable -NoLogo -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.ToString()' +if ($actual -ne $version) { throw "Expected PowerShell $version at $engineDirectory; found $actual." } +$env:DT_ISEBERG_PSHOME = $engineDirectory +if ($env:GITHUB_ENV) { "DT_ISEBERG_PSHOME=$engineDirectory" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append } +Write-Host "Test-only PowerShell ${version}: $engineDirectory (never include this directory in DT packages)." diff --git a/scripts/Stage-MacOsApp.ps1 b/scripts/Stage-MacOsApp.ps1 index c2ecce4..3ba2f70 100644 --- a/scripts/Stage-MacOsApp.ps1 +++ b/scripts/Stage-MacOsApp.ps1 @@ -2,7 +2,7 @@ #Requires -Version 7 <# .SYNOPSIS - Stages a Devolutions Terminal .app bundle from a NativeAOT publish + Stages a Devolutions Terminal .app bundle from a single-file managed or NativeAOT publish directory: copies the published output, writes Info.plist with the version substituted in, generates the .icns app icon, copies license and third-party notices, and normalizes timestamps for reproducibility. @@ -67,10 +67,14 @@ $required = @( ) foreach ($path in $required) { if (-not (Test-Path -LiteralPath (Join-Path $publishDir $path) -PathType Leaf)) { - throw "NativeAOT publish output is missing $path for $Rid." + throw "Desktop publish output is missing $path for $Rid." } } +if (Test-Path -LiteralPath (Join-Path $publishDir 'Devolutions.Terminal.dll')) { + throw 'macOS packaging requires the default NativeAOT publish or an explicitly supplied self-contained single-file managed publish. A loose managed host needs its runtime configuration and cannot use this code-only bundle layout.' +} + if (Test-Path -LiteralPath $AppPath) { Remove-Item -LiteralPath $AppPath -Recurse -Force } @@ -84,8 +88,9 @@ Get-ChildItem -LiteralPath $macosDir -Recurse -Filter '*.pdb' | Remove-Item -For Get-ChildItem -LiteralPath $macosDir -Recurse -Filter '*.dbg' | Remove-Item -Force Get-ChildItem -LiteralPath $macosDir -Recurse -Directory -Filter '*.dSYM' | Remove-Item -Recurse -Force -# NativeAOT compiles runtime configuration into the executable. Leaving this -# build-time sidecar in Contents/MacOS makes Developer ID bundle signing treat +# NativeAOT compiles configuration into the executable; single-file managed +# publishes bundle it. Leaving a redundant build-time sidecar +# in Contents/MacOS makes Developer ID bundle signing treat # it as unsigned nested code. Get-ChildItem -LiteralPath $macosDir -File -Filter '*.runtimeconfig.json' | Remove-Item -Force diff --git a/src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs b/src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs new file mode 100644 index 0000000..1e41800 --- /dev/null +++ b/src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs @@ -0,0 +1,645 @@ +#if POWERSHELL_ISE +using System.Globalization; +using System.Text; +using System.Management.Automation.Language; +using Avalonia.Media; +using Avalonia.Threading; +using Devolutions.Terminal.Connection; +using Iseberg; +using Iseberg.Core; + +namespace Devolutions.Terminal.App.Connections; + +/// VT transport and line editing over an existing Iseberg runspace, not a second shell process. +public sealed class IsebergTerminalConnection : IRestartableTerminalConnection +{ + private const string Escape = "\u001b"; + private readonly WorkbenchControl _workbench; + private readonly SessionModel _session; + private readonly Action _focusInput; + private readonly Decoder _decoder = Encoding.UTF8.GetDecoder(); + private readonly StringBuilder _line = new(); + private readonly StringBuilder _escapeSequence = new(); + private InputRequest? _input; + private string _inputDraft = ""; + private int _caret; + private int _revision; + private bool _promptShown; + private string _prefix = ""; + private bool _acceptsCommands; + private bool _inputDisabled = true; + private bool _pasting; + private bool _sawCarriageReturn; + private bool _disposed; + private string? _completionOriginal; + private string? _completionResult; + private CompletionSet? _completions; + private int _completionIndex; + private int _completionCaret; + private EditorTheme _theme = EditorThemePresets.Classic(); + private readonly List _undo = []; + private readonly List _redo = []; + private int? _selectionAnchor; + private char? _pendingHighSurrogate; + private bool _syntaxHighlighting = true; + private sealed record InputEdit(string Text, int Caret); + + public IsebergTerminalConnection(WorkbenchControl workbench, SessionModel session, Action? focusInput = null) + { + _workbench = workbench; + _session = session; + _focusInput = focusInput ?? (() => { }); + } + + public event EventHandler>? OutputReceived; + public event EventHandler? Exited; + public event EventHandler? SessionExited; + public event EventHandler? Faulted; + public bool IsRunning { get; private set; } + public int Columns { get; private set; } = 80; + public int Rows { get; private set; } = 24; + public TerminalConnectionCapabilities Capabilities => TerminalConnectionCapabilities.Resize; + public TerminalConnectionState State { get; private set; } = TerminalConnectionState.NotConnected; + public TerminalProcessMetadata? ProcessMetadata => null; + public TerminalExitInfo? LastExitInfo { get; private set; } + public bool IsInputEnabled => IsRunning && !_inputDisabled && + (_input is not null || _acceptsCommands && (!_session.IsConsoleSubmissionPending || + _session.Engine.IsDebuggerPaused || _session.Engine.IsNestedPromptActive)); + public bool HasPendingInput => _input is not null; + public string InputText => _input?.Secret == true ? "" : _line.ToString(); + public int CaretOffset => _input?.Secret == true ? 0 : _caret; + public int InputRevision => _revision; + public bool IsSecretInput => _input?.Secret == true; + public bool CanUndo => IsInputEnabled && _input?.Secret != true && _undo.Count > 0; + public bool CanRedo => IsInputEnabled && _input?.Secret != true && _redo.Count > 0; + public int SelectionStart => Math.Min(_selectionAnchor ?? _caret, _caret); + public int SelectionLength => Math.Abs((_selectionAnchor ?? _caret) - _caret); + public string SelectedInput => _input?.Secret == true ? "" : _line.ToString(SelectionStart, SelectionLength); + public event EventHandler? InputChanged; + public void ApplyTheme(EditorTheme theme) { _theme = theme.Copy(); Redraw(); } + public void ApplyPreferences(UserSettings preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + Redraw(); + } + + public void InsertNewLine() + { + if (!IsInputEnabled || _input is not null) return; + Insert("\n"); + } + + public void MoveCaret(int direction, bool extendSelection = false, bool toBoundary = false) + { + if (!IsInputEnabled) return; + _selectionAnchor = extendSelection ? _selectionAnchor ?? _caret : null; + _caret = toBoundary ? direction < 0 ? 0 : _line.Length : direction < 0 ? PreviousBoundary(_caret) : NextBoundary(_caret); + ++_revision; + _completions = null; + Redraw(); + InputChanged?.Invoke(this, EventArgs.Empty); + } + + public void SelectInput() + { + if (!IsInputEnabled) return; + _selectionAnchor = 0; + _caret = _line.Length; + ++_revision; + Redraw(); + InputChanged?.Invoke(this, EventArgs.Empty); + } + + public void DeleteSelection() + { + if (!IsInputEnabled || SelectionLength == 0) return; + RememberEdit(); + RemoveSelection(); + Changed(); + } + + public void Undo() => RestoreEdit(_undo, _redo); + public void Redo() => RestoreEdit(_redo, _undo); + private void RestoreEdit(List source, List destination) + { + if (!IsInputEnabled || _input?.Secret == true || source.Count == 0) return; + destination.Add(new(_line.ToString(), _caret)); + var edit = source[^1]; + source.RemoveAt(source.Count - 1); + _line.Clear().Append(edit.Text); + _caret = edit.Caret; + Changed(); + } + + private void RememberEdit() + { + if (_input?.Secret == true || _pasting) return; + _undo.Add(new(_line.ToString(), _caret)); + if (_undo.Count > 100) _undo.RemoveAt(0); + _redo.Clear(); + } + + private void RemoveSelection() + { + var start = SelectionStart; + var length = SelectionLength; + if (length > 0) { _line.Remove(start, length); _caret = start; } + _selectionAnchor = null; + } + + public async Task RequestCompletionAsync(CancellationToken cancellationToken = default) + { + if (!IsInputEnabled || _input is not null) return null; + var text = InputText; + var caret = _caret; + var revision = _revision; + var result = await _session.Engine.CompleteAsync(text, caret, cancellationToken); + return IsInputEnabled && _input is null && revision == _revision ? result : null; + } + + public bool ApplyCompletion(CompletionSet completion, int index, int expectedRevision) + { + if (!IsInputEnabled || _input is not null || expectedRevision != _revision) return false; + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, completion.Matches.Count); + if (completion.Start < 0 || completion.Start > _line.Length || + completion.Length < 0 || completion.Length > _line.Length - completion.Start) + throw new ArgumentException("The completion span is outside the input.", nameof(completion)); + RememberEdit(); + _line.Remove(completion.Start, completion.Length).Insert(completion.Start, completion.Matches[index].CompletionText); + _caret = completion.Start + completion.Matches[index].CompletionText.Length; + Changed(); + return true; + } + + public Task StartAsync(string commandLine, string? workingDirectory, int columns, int rows, + CancellationToken cancellationToken = default) => StartAsync( + new TerminalLaunchOptions { CommandLine = commandLine, WorkingDirectory = workingDirectory, Columns = columns, Rows = rows }, + cancellationToken); + + public Task StartAsync(TerminalLaunchOptions options, CancellationToken cancellationToken = default) + { + Dispatcher.UIThread.VerifyAccess(); + cancellationToken.ThrowIfCancellationRequested(); + ObjectDisposedException.ThrowIf(_disposed, this); + if (State != TerminalConnectionState.NotConnected) throw new InvalidOperationException("The Iseberg transport has already started."); + Resize(options.Columns, options.Rows); + _session.Engine.Output += WriteOutput; + _session.Engine.InputRequested += OnInputRequested; + State = TerminalConnectionState.Connected; + IsRunning = true; + Emit(Escape + "[?2004h"); + return Task.CompletedTask; + } + + public Task RestartAsync(TerminalLaunchOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Duplicate or reopen the Iseberg tab to create a fresh runspace."); + + public void Write(ReadOnlySpan data) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!IsRunning) throw new InvalidOperationException("The Iseberg terminal is not connected."); + var bytes = data.ToArray(); + if (Dispatcher.UIThread.CheckAccess()) ConsumeObserved(bytes); + else Dispatcher.UIThread.Post(() => { if (IsRunning) ConsumeObserved(bytes); }); + } + + public void Write(string text) => Write(Encoding.UTF8.GetBytes(text)); + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Write(data.Span); + return ValueTask.CompletedTask; + } + + public void Resize(int columns, int rows) + { + ArgumentOutOfRangeException.ThrowIfLessThan(columns, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + ObjectDisposedException.ThrowIf(_disposed, this); + Columns = columns; + Rows = rows; + _session.Engine.SetTerminalSize(columns, rows); + } + + public void RefreshState(bool acceptsCommands, bool inputDisabled) + { + if (!IsRunning) return; + _acceptsCommands = acceptsCommands; + _inputDisabled = inputDisabled; + if (!IsInputEnabled || _input is not null) return; + if (!_promptShown) + { + Emit(Escape + "]133;D;0\a" + Escape + "]133;A\a"); + ShowPrompt(_session.Engine.IsNestedPromptActive ? _session.Engine.NestedPrompt : + _session.Engine.IsDebuggerPaused ? _session.Engine.DebugPrompt : _session.Engine.Prompt); + } + } + + public void WriteOutput(OutputEntry entry) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => WriteOutput(entry)); + return; + } + if (!IsRunning) return; + if (_promptShown) HidePrompt(); + if (entry.Kind == OutputKind.Command) + { + _acceptsCommands = false; + Emit(Escape + "]133;B\a" + Escape + "]133;C\a"); + } + var style = entry.Style; + var codes = new List(); + if (style?.Foreground is { } foreground) codes.Add(ColorCode(foreground, 38)); + else if (entry.Kind is OutputKind.Error or OutputKind.Warning or OutputKind.Verbose or OutputKind.Debug) + codes.Add(ColorCode(_theme.Colors["Stream." + entry.Kind], 38)); + if (style?.Background is { } background) codes.Add(ColorCode(background, 48)); + if (style?.Bold == true) codes.Add("1"); + if (style?.Underline == true) codes.Add("4"); + if (style?.Inverse == true) codes.Add("7"); + if (codes.Count > 0) Emit(Escape + "[" + string.Join(';', codes) + "m"); + Emit(entry.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n", "\r\n", StringComparison.Ordinal)); + if (codes.Count > 0) Emit(Escape + "[0m"); + } + + private static string ColorCode(string value, int target) + { + var color = Color.Parse(value); + return $"{target};2;{color.R};{color.G};{color.B}"; + } + + public void Clear() + { + if (!IsRunning) return; + _promptShown = false; + Emit(Escape + "[2J" + Escape + "[3J" + Escape + "[H"); + RefreshState(_acceptsCommands, _inputDisabled); + } + + private void OnInputRequested(InputRequest request) => + Dispatcher.UIThread.Post(() => + { + if (!IsRunning || _workbench.IsDisposed) { request.Response.TrySetCanceled(); return; } + HidePrompt(); + _inputDraft = _line.ToString(); + _line.Clear(); + _caret = 0; + _input = request; + _undo.Clear(); _redo.Clear(); + _workbench.IsEnabled = true; + if (request.Message is not ("Enter a value:" or "Enter a secure value:")) + { + Emit("\r\n" + request.Caption + "\r\n" + request.Message.Replace("\n", "\r\n", StringComparison.Ordinal) + "\r\n"); + for (var index = 0; index < request.Choices.Count; index++) + Emit($"[{index}] {request.Choices[index].Label} {request.Choices[index].Help}\r\n"); + } + ShowPrompt(request.Choices.Count > 0 ? "Choice: " : ""); + if (!_inputDisabled) _focusInput(); + _ = request.Response.Task.ContinueWith(_ => Dispatcher.UIThread.Post(() => + { + if (ReferenceEquals(_input, request)) + { + HidePrompt(); + _input = null; + ReplaceLine(_inputDraft); + } + }), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + }); + + private void ConsumeObserved(byte[] data) + { + try { Consume(data); } + catch (Exception error) + { + State = TerminalConnectionState.Failed; + Faulted?.Invoke(this, error); + } + } + + private void Consume(byte[] data) + { + var characters = new char[Encoding.UTF8.GetMaxCharCount(data.Length)]; + var count = _decoder.GetChars(data, characters, flush: false); + for (var index = 0; index < count; index++) Consume(characters[index]); + } + + private void Consume(char character) + { + if (_escapeSequence.Length > 0) + { + _escapeSequence.Append(character); + if (_escapeSequence.Length == 2 && character is '[' or 'O') return; + if (_escapeSequence.Length > 2 && character is >= '\x40' and <= '\x7e') + { + var sequence = _escapeSequence.ToString(); + _escapeSequence.Clear(); + if (sequence == Escape + "[200~") { _sawCarriageReturn = false; RememberEdit(); _pasting = true; return; } + if (sequence == Escape + "[201~") { _pasting = false; Redraw(); return; } + if (_pasting) Insert(sequence); + else HandleEscape(sequence); + } + else if (_escapeSequence.Length == 2 || _escapeSequence.Length > 64) + { + _escapeSequence.Clear(); + } + return; + } + if (character == '\x1b') { _escapeSequence.Append(character); return; } + if (!IsInputEnabled && (character != '\x03' || _inputDisabled)) return; + if (char.IsHighSurrogate(character)) { _pendingHighSurrogate = character; return; } + if (_pendingHighSurrogate is { } high) + { + _pendingHighSurrogate = null; + Insert(new string([high, character])); + return; + } + if (character == '\n' && _sawCarriageReturn) { _sawCarriageReturn = false; return; } + _sawCarriageReturn = character == '\r'; + if (_pasting) + { + Insert(character is '\r' or '\n' ? "\n" : character.ToString()); + return; + } + if (character == '\x03') + { + HidePrompt(); + Emit("^C\r\n"); + _input?.Response.TrySetCanceled(); + ReplaceLine(""); + Observe(_session.Engine.StopAsync()); + return; + } + switch (character) + { + case '\r': case '\n': Submit(); break; + case '\t': Observe(CompleteAsync()); break; + case '\b': case '\x7f': Backspace(); break; + case '\x01': MoveCaret(-1, toBoundary: true); break; + case '\x05': MoveCaret(1, toBoundary: true); break; + case '\x0b': RememberEdit(); _line.Remove(_caret, _line.Length - _caret); Changed(); break; + case '\x15': RememberEdit(); _line.Remove(0, _caret); _caret = 0; Changed(); break; + case '\x17': + while (_caret > 0 && char.IsWhiteSpace(_line[_caret - 1])) Backspace(); + while (_caret > 0 && !char.IsWhiteSpace(_line[_caret - 1])) Backspace(); + break; + case '\x0c': Clear(); break; + case '\x1a': Undo(); break; + case '\x19': Redo(); break; + default: + if (!char.IsControl(character)) Insert(character.ToString()); + break; + } + } + + private void HandleEscape(string sequence) + { + if (!IsInputEnabled) return; + switch (sequence) + { + case "\x1b[A": case "\x1bOA": NavigateHistory(-1); break; + case "\x1b[B": case "\x1bOB": NavigateHistory(1); break; + case "\x1b[C": case "\x1bOC": MoveCaret(1); break; + case "\x1b[D": case "\x1bOD": MoveCaret(-1); break; + case "\x1b[H": case "\x1bOH": case "\x1b[1~": MoveCaret(-1, toBoundary: true); break; + case "\x1b[F": case "\x1bOF": case "\x1b[4~": MoveCaret(1, toBoundary: true); break; + case "\x1b[1;2C": MoveCaret(1, extendSelection: true); break; + case "\x1b[1;2D": MoveCaret(-1, extendSelection: true); break; + case "\x1b[13;2u": InsertNewLine(); break; + case "\x1b[3~": + if (SelectionLength > 0) DeleteSelection(); + else if (_caret < _line.Length) { RememberEdit(); _line.Remove(_caret, NextBoundary(_caret) - _caret); Changed(); } + break; + case "\x1b[Z": Observe(CompleteAsync(backwards: true)); break; + } + } + + private int PreviousBoundary(int offset) => + StringInfo.ParseCombiningCharacters(_line.ToString()).LastOrDefault(boundary => boundary < offset); + private int NextBoundary(int offset) => + StringInfo.ParseCombiningCharacters(_line.ToString()).FirstOrDefault(boundary => boundary > offset, _line.Length); + private void Backspace() + { + if (SelectionLength > 0) { DeleteSelection(); return; } + if (_caret == 0) return; + RememberEdit(); + var previous = PreviousBoundary(_caret); + _line.Remove(previous, _caret - previous); + _caret = previous; + Changed(); + } + + private void Insert(string text) + { + RememberEdit(); + RemoveSelection(); + _line.Insert(_caret, text); + _caret += text.Length; + Changed(); + } + + private void ReplaceLine(string text) + { + _line.Clear().Append(text); + _caret = _line.Length; + Changed(); + } + + private void Changed() + { + ++_revision; + _completions = null; + _selectionAnchor = null; + if (_input is null) _session.Input = _line.ToString(); + Redraw(); + InputChanged?.Invoke(this, EventArgs.Empty); + } + + private void NavigateHistory(int delta) + { + if (_input is not null) return; + RememberEdit(); + if (_session.HistoryIndex == _session.History.Count) _session.DraftInput = _line.ToString(); + _session.HistoryIndex = Math.Clamp(_session.HistoryIndex + delta, 0, _session.History.Count); + ReplaceLine(_session.HistoryIndex == _session.History.Count ? _session.DraftInput : _session.History[_session.HistoryIndex]); + } + + private void Submit() + { + var text = _line.ToString(); + if (_input is { } request) + { + Emit("\r\n"); + _promptShown = false; + _input = null; + ReplaceLine(_inputDraft); + request.Response.TrySetResult(text); + return; + } + if (EditorAnalysis.Analyze(text).Errors.Any(error => error.IncompleteInput || + error.ErrorId is "MissingEndParenthesisInExpression" or "MissingEndParenthesisInMethodCall")) + { + Insert("\n"); + return; + } + if (string.IsNullOrWhiteSpace(text)) + { + Emit("\r\n"); + _promptShown = false; + ReplaceLine(""); + RefreshState(_acceptsCommands, _inputDisabled); + return; + } + _acceptsCommands = false; + HidePrompt(); + ReplaceLine(""); + _undo.Clear(); _redo.Clear(); + Observe(_workbench.SubmitConsoleInputAsync(text)); + } + + public async Task CompleteAsync(bool backwards = false) + { + if (!IsInputEnabled || _input is not null) return; + var text = _line.ToString(); + if (_completions is not null && text == _completionResult && _caret == _completionCaret) + { + _completionIndex = (_completionIndex + (backwards ? _completions.Matches.Count - 1 : 1)) % _completions.Matches.Count; + } + else + { + var revision = _revision; + var caret = _caret; + var result = await _session.Engine.CompleteAsync(text, _caret); + if (!IsInputEnabled || _input is not null || revision != _revision || caret != _caret || result.Matches.Count == 0) return; + _completionOriginal = text; + _completions = result; + _completionIndex = backwards ? result.Matches.Count - 1 : 0; + } + var matches = _completions ?? throw new InvalidOperationException("The completion set is unavailable."); + var original = _completionOriginal ?? throw new InvalidOperationException("The original completion input is unavailable."); + var replacement = matches.Matches[_completionIndex].CompletionText; + RememberEdit(); + _line.Clear().Append(original.Remove(matches.Start, matches.Length).Insert(matches.Start, replacement)); + _caret = matches.Start + replacement.Length; + _completionCaret = _caret; + _completionResult = _line.ToString(); + _selectionAnchor = null; + _session.Input = _completionResult; + ++_revision; + Redraw(); + InputChanged?.Invoke(this, EventArgs.Empty); + } + + private void ShowPrompt(string prefix) + { + _prefix = prefix; + _promptShown = true; + Emit(Escape + "[s"); + Redraw(); + } + + private void HidePrompt() + { + if (!_promptShown) return; + Emit(Escape + "[u" + Escape + "[J"); + _promptShown = false; + } + + private string DisplayInput(string value, bool highlighted = false) + { + if (_input?.Secret == true) return new string('*', StringInfo.ParseCombiningCharacters(value).Length); + var display = new StringBuilder(); + Token[] tokens = []; + if (highlighted && _syntaxHighlighting && _input is null) Parser.ParseInput(value, out tokens, out _); + string? activeColor = null; + var tokenIndex = 0; + var selected = false; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + if (highlighted) + { + var isSelected = index >= SelectionStart && index < SelectionStart + SelectionLength; + if (selected != isSelected) { display.Append(Escape).Append(isSelected ? "[7m" : "[27m"); selected = isSelected; } + while (tokenIndex < tokens.Length && tokens[tokenIndex].Extent.EndOffset <= index) tokenIndex++; + var token = tokenIndex < tokens.Length && tokens[tokenIndex].Extent.StartOffset <= index ? tokens[tokenIndex] : null; + var key = token?.Kind switch + { + TokenKind.Comment => "Comment", + TokenKind.StringExpandable or TokenKind.StringLiteral or TokenKind.HereStringExpandable or TokenKind.HereStringLiteral => "String", + TokenKind.Variable or TokenKind.SplattedVariable => "Variable", + TokenKind.Number => "Number", + TokenKind.Parameter => "Parameter", + _ when token?.TokenFlags.HasFlag(TokenFlags.Keyword) == true => "Keyword", + _ when token?.TokenFlags.HasFlag(TokenFlags.CommandName) == true => "Command", + _ when token?.TokenFlags.HasFlag(TokenFlags.TypeName) == true => "Type", + _ => "Foreground" + }; + var color = _theme.Colors["Console." + key]; + if (activeColor != color) { display.Append(Escape).Append('[').Append(ColorCode(color, 38)).Append('m'); activeColor = color; } + } + if (character == '\n') display.Append("\r\n>> "); + else if (character == '\t') display.Append(" "); + else if (char.IsControl(character)) display.Append('^').Append((char)(character + 64)); + else display.Append(character); + } + if (highlighted) display.Append(Escape).Append("[0m"); + return display.ToString(); + } + + private void Redraw() + { + if (!_promptShown || !IsRunning) return; + var text = _line.ToString(); + Emit(Escape + "[u" + Escape + "[J" + _prefix + DisplayInput(text, highlighted: true)); + if (_caret != text.Length) Emit(Escape + "[u" + _prefix + DisplayInput(text[.._caret], highlighted: true)); + } + + private void Observe(Task task) => _ = ObserveAsync(task); + private async Task ObserveAsync(Task task) + { + try { await task; } + catch (Exception error) + { + if (IsRunning) await _workbench.ReportConsoleErrorAsync(error); + else System.Diagnostics.Trace.TraceError("Iseberg terminal operation failed during close: {0}", error); + } + finally + { + if (IsRunning) RefreshState( + _workbench.IsStarted && (_session.Engine.State == SessionState.Ready || _session.Engine.IsDebuggerPaused || _session.Engine.IsNestedPromptActive), + _inputDisabled); + } + } + + private void Emit(string text) => OutputReceived?.Invoke(this, Encoding.UTF8.GetBytes(text)); + + public Task CloseAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsRunning) return Task.CompletedTask; + IsRunning = false; + State = TerminalConnectionState.Closed; + _session.Engine.Output -= WriteOutput; + _session.Engine.InputRequested -= OnInputRequested; + _input?.Response.TrySetCanceled(); + _input = null; + _line.Clear(); + _undo.Clear(); _redo.Clear(); + InputChanged?.Invoke(this, EventArgs.Empty); + LastExitInfo = new(null, 0, TerminalExitReason.Closed, false, DateTimeOffset.UtcNow); + SessionExited?.Invoke(this, LastExitInfo); + Exited?.Invoke(this, 0); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + await CloseAsync(); + _disposed = true; + State = TerminalConnectionState.Disposed; + } +} +#endif diff --git a/src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs b/src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs new file mode 100644 index 0000000..7b58a80 --- /dev/null +++ b/src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs @@ -0,0 +1,124 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using System.Text.Json; + +namespace Devolutions.Terminal.App.Connections; + +public sealed record PowerShellInstallation(string Home, string Version, string RuntimeVersion, string Architecture) +{ + public const string HomeVariable = "DT_ISEBERG_PSHOME"; + public static Version MinimumVersion { get; } = new(7, 6, 6); + + public static PowerShellInstallation Find() + { + var home = Environment.GetEnvironmentVariable(HomeVariable); + var executable = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh"; + var start = new ProcessStartInfo(string.IsNullOrWhiteSpace(home) ? executable : Path.Combine(home, executable)) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var argument in new[] + { + "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + """ + [pscustomobject]@{ + Home = $PSHOME + Version = $PSVersionTable.PSVersion.ToString() + RuntimeVersion = [Environment]::Version.ToString() + Architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + } | ConvertTo-Json -Compress + """ + }) + start.ArgumentList.Add(argument); + + using var process = new Process { StartInfo = start }; + try { process.Start(); } + catch (Win32Exception exception) + { + throw new InvalidOperationException( + $"Install PowerShell {MinimumVersion} (7.6.x) on PATH, or set {HomeVariable} to its installation directory.", exception); + } + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(15000)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + throw new InvalidOperationException("PowerShell did not respond to the installation check within 15 seconds."); + } + if (process.ExitCode != 0) + throw new InvalidOperationException($"PowerShell installation check failed: {error.GetAwaiter().GetResult().Trim()}"); + PowerShellInstallation installation; + try + { + installation = JsonSerializer.Deserialize(output.GetAwaiter().GetResult()) + ?? throw new InvalidOperationException("PowerShell returned an empty installation description."); + } + catch (JsonException exception) + { + throw new InvalidOperationException("PowerShell returned an invalid installation description.", exception); + } + installation.Validate(Environment.Version, RuntimeInformation.ProcessArchitecture); + return installation; + } + + public void Validate(Version runtimeVersion, Architecture architecture) + { + if (!System.Version.TryParse(Version, out var version) || + version.Major != MinimumVersion.Major || version.Minor != MinimumVersion.Minor || + version < MinimumVersion) + throw new InvalidOperationException($"Iseberg requires PowerShell {MinimumVersion} or newer in the 7.6.x series; found {Version}."); + if (!System.Version.TryParse(RuntimeVersion, out var runtime) || + runtime.Major != runtimeVersion.Major || runtime.Minor != runtimeVersion.Minor) + throw new InvalidOperationException($"PowerShell must use .NET {runtimeVersion.Major}.{runtimeVersion.Minor}; found {RuntimeVersion}."); + if (!string.Equals(Architecture, architecture.ToString(), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"PowerShell architecture must match Iseberg ({architecture}); found {Architecture}."); + if (string.IsNullOrWhiteSpace(Home) || !Path.IsPathFullyQualified(Home) || + !File.Exists(Path.Combine(Home, "System.Management.Automation.dll")) || + !File.Exists(Path.Combine(Home, "pwsh.dll"))) + throw new InvalidOperationException($"PowerShell installation is incomplete: {Home}."); + } + + public void Register() + { + var resolver = new AssemblyDependencyResolver(Path.Combine(Home, "pwsh.dll")); + Assembly? ResolveAssembly(AssemblyLoadContext context, AssemblyName name) + { + var path = resolver.ResolveAssemblyToPath(name); + if (path is null && name.Name is { } assemblyName) + { + var candidate = Path.Combine(Home, assemblyName + ".dll"); + if (File.Exists(candidate)) path = candidate; + } + return path is null ? null : context.LoadFromAssemblyPath(path); + } + IntPtr ResolveNative(Assembly assembly, string name) + { + var path = resolver.ResolveUnmanagedDllToPath(name); + if (path is null) + { + var fileName = OperatingSystem.IsWindows() ? name + ".dll" + : OperatingSystem.IsMacOS() ? "lib" + name + ".dylib" : "lib" + name + ".so"; + var candidate = Path.Combine(Home, fileName); + if (File.Exists(candidate)) path = candidate; + } + return path is null ? IntPtr.Zero : NativeLibrary.Load(path); + } + AssemblyLoadContext.Default.Resolving += ResolveAssembly; + AssemblyLoadContext.Default.ResolvingUnmanagedDll += ResolveNative; + // Bind the engine before JIT-compiling any desktop or core code that references its types. + try { AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.Combine(Home, "System.Management.Automation.dll")); } + catch + { + AssemblyLoadContext.Default.Resolving -= ResolveAssembly; + AssemblyLoadContext.Default.ResolvingUnmanagedDll -= ResolveNative; + throw; + } + } +} diff --git a/src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs b/src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs new file mode 100644 index 0000000..554bdf4 --- /dev/null +++ b/src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs @@ -0,0 +1,20 @@ +#if POWERSHELL_ISE +namespace Devolutions.Terminal.App.Connections; + +public static class PowerShellIseRuntime +{ + private static readonly object Sync = new(); + private static Task? _initialization; + + public static Task InitializeAsync() + { + lock (Sync) + { + // A failed discovery can be retried after the user installs PowerShell or changes the override. + if (_initialization is null || _initialization.IsFaulted) + _initialization = Task.Run(() => PowerShellInstallation.Find().Register()); + return _initialization; + } + } +} +#endif diff --git a/src/Devolutions.Terminal.App/Connections/TerminalConnectionFactory.cs b/src/Devolutions.Terminal.App/Connections/TerminalConnectionFactory.cs index 30a3e94..1cba280 100644 --- a/src/Devolutions.Terminal.App/Connections/TerminalConnectionFactory.cs +++ b/src/Devolutions.Terminal.App/Connections/TerminalConnectionFactory.cs @@ -29,6 +29,11 @@ public TerminalConnectionFactory( public IRestartableTerminalConnection Create(ProfileSettings profile) { ArgumentNullException.ThrowIfNull(profile); + if (profile.Kind != ProfileKind.Terminal) + { + throw new NotSupportedException($"Profile '{profile.Name}' is not a terminal connection profile."); + } + if (!Guid.TryParse(profile.ConnectionType, out var connectionType) || connectionType != AzureCloudShellConnection.ConnectionTypeGuid) { diff --git a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj index b492f09..41ca450 100644 --- a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj +++ b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj @@ -5,6 +5,14 @@ Avalonia application shell for tabs, panes, actions, and windows. true + + $(DefineConstants);POWERSHELL_ISE + false + false + + + + @@ -28,5 +36,7 @@ + diff --git a/src/Devolutions.Terminal.App/Models/LayoutDescriptors.cs b/src/Devolutions.Terminal.App/Models/LayoutDescriptors.cs index d9f5209..49e8892 100644 --- a/src/Devolutions.Terminal.App/Models/LayoutDescriptors.cs +++ b/src/Devolutions.Terminal.App/Models/LayoutDescriptors.cs @@ -8,6 +8,9 @@ namespace Devolutions.Terminal.App.Models; public sealed class TerminalSessionDescriptor { + public ProfileKind Kind { get; set; } + public bool IseLoadProfiles { get; set; } + public string IseColorTheme { get; set; } = IsebergThemes.Classic; public Guid SessionId { get; set; } = Guid.NewGuid(); public string? ProfileId { get; set; } public string ProfileName { get; set; } = string.Empty; @@ -47,7 +50,7 @@ public sealed class TabLayoutDescriptor public sealed class TerminalWindowLayoutDescriptor { - public const int CurrentVersion = 1; + public const int CurrentVersion = 2; public int Version { get; set; } = CurrentVersion; public Guid? ActiveTabId { get; set; } @@ -161,7 +164,7 @@ public static WindowLayoutState ToApplicationState( public static void Validate(TerminalWindowLayoutDescriptor layout) { ArgumentNullException.ThrowIfNull(layout); - if (layout.Version != TerminalWindowLayoutDescriptor.CurrentVersion) + if (layout.Version is < 1 or > TerminalWindowLayoutDescriptor.CurrentVersion) { throw new InvalidOperationException($"Unsupported terminal layout version '{layout.Version}'."); } @@ -185,6 +188,9 @@ public static void Validate(TerminalWindowLayoutDescriptor layout) } var sessions = new HashSet(); + if (ContainsNonTerminalSession(tab.Root) && + (tab.Root.Session is not { Kind: ProfileKind.PowerShellIse } || tab.ZoomedSessionId is not null)) + throw new InvalidOperationException("An Iseberg workbench must occupy a whole tab."); ValidatePane(tab.Root, sessions); if (!sessions.Contains(tab.ActiveSessionId) || (tab.ZoomedSessionId is { } zoomed && !sessions.Contains(zoomed))) @@ -208,6 +214,8 @@ private static void ValidatePane(PaneLayoutDescriptor? pane, ISet sessions if (pane.Session is { } session) { + if (session.Kind is not (ProfileKind.Terminal or ProfileKind.PowerShellIse)) + throw new InvalidOperationException("The saved session has an unsupported profile type."); if (pane.First is not null || pane.Second is not null || pane.Orientation is not null || @@ -232,6 +240,11 @@ pane.Orientation is not null || private static double NormalizeRatio(double ratio) => Math.Round(Math.Clamp(double.IsFinite(ratio) ? ratio : 0.5, 0.1, 0.9), 6); + + private static bool ContainsNonTerminalSession(PaneLayoutDescriptor pane) => + pane.Session is { Kind: not ProfileKind.Terminal } || + pane.First is not null && ContainsNonTerminalSession(pane.First) || + pane.Second is not null && ContainsNonTerminalSession(pane.Second); } [JsonSourceGenerationOptions( diff --git a/src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs b/src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs new file mode 100644 index 0000000..e198492 --- /dev/null +++ b/src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs @@ -0,0 +1,294 @@ +#if POWERSHELL_ISE +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using Devolutions.Terminal.App.Connections; +using Devolutions.Terminal.Settings; +using Iseberg; +using Iseberg.Core; + +namespace Devolutions.Terminal.App.Views; + +internal sealed class DtIsebergConsole : IWorkbenchConsole +{ + private readonly ProfileSettings profile; + private readonly Popup completionPopup; + private readonly ListBox completionList; + private readonly TextBlock completionDescription; + private readonly Border completionBorder; + private readonly DispatcherTimer completionTimer = new() { Interval = TimeSpan.FromMilliseconds(200) }; + private IsebergTerminalConnection? _connection; + private EditorTheme? _theme; + private string? _fontFamily; + private double? _fontSizeDips; + private WorkbenchControl? workbench; + private UserSettings preferences = new(); + private CancellationTokenSource? completionCancellation; + private CompletionSet? completions; + private int completionRevision; + private bool acceptingCompletion; + public DtIsebergConsole(TermControl terminal, Control view, ProfileSettings profile) + { + Terminal = terminal; + this.profile = profile; + completionList = new ListBox { MaxHeight = 220, MinWidth = 250, Focusable = false }; + completionDescription = new TextBlock { TextWrapping = TextWrapping.Wrap, MaxWidth = 480, Margin = new Thickness(8) }; + completionBorder = new Border + { + BorderThickness = new Thickness(1), MaxWidth = 500, + Child = new StackPanel { Children = { completionList, completionDescription } } + }; + AutomationProperties.SetName(completionList, "PowerShell console completions"); + AutomationProperties.SetName(completionDescription, "PowerShell completion description"); + completionPopup = new Popup + { + PlacementTarget = terminal, Placement = PlacementMode.BottomEdgeAlignedLeft, + IsLightDismissEnabled = true, Child = completionBorder + }; + View = new Grid { Children = { view, completionPopup } }; + terminal.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + completionPopup.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + completionList.SelectionChanged += (_, _) => RefreshDescription(); + completionList.DoubleTapped += (_, _) => AcceptCompletion(); + completionTimer.Tick += async (_, _) => + { + completionTimer.Stop(); + await ObserveCompletionAsync(); + }; + } + public TermControl Terminal { get; } + public Control View { get; } + public bool HasSelection => _connection?.SelectionLength > 0 || Terminal.HasSelection; + public bool IsInputEnabled => _connection?.IsInputEnabled == true; + public bool HasPendingInput => _connection?.HasPendingInput == true; + public bool CanUndo => _connection?.CanUndo == true; + public bool CanRedo => _connection?.CanRedo == true; + public bool CanCut => IsInputEnabled && _connection?.SelectedInput.Length > 0; + public string InputText => _connection?.InputText ?? ""; + public int CaretOffset => _connection?.CaretOffset ?? 0; + + public async Task StartAsync(WorkbenchControl workbench, SessionModel session) + { + this.workbench = workbench; + var connection = new IsebergTerminalConnection(workbench, session, () => Terminal.Focus()); + _connection = connection; + connection.InputChanged += OnInputChanged; + Terminal.ConnectionFactory = _ => connection; + Terminal.AccessibleName = "Iseberg PowerShell terminal"; + await Terminal.StartAsync(profile, 80, 24); + if (_fontSizeDips is { } size) SetFontSize(size); + if (_theme is not null) ApplyAppearance(_theme, _fontFamily ?? profile.FontFace); + connection.Clear(); + } + + public void Write(OutputEntry entry) => _connection?.WriteOutput(entry); + public void RefreshState(bool acceptsCommands, bool inputDisabled) + { + _connection?.RefreshState(acceptsCommands, inputDisabled); + if (!IsInputEnabled) CloseCompletion(); + } + public void Focus() => Terminal.Focus(); + public void Clear() => _connection?.Clear(); + public async Task CopyAsync() + { + if (_connection?.SelectedInput is { Length: > 0 } selected) + await (TopLevel.GetTopLevel(Terminal)?.Clipboard ?? throw new InvalidOperationException("The console clipboard is unavailable.")) + .SetTextAsync(selected); + else await Terminal.CopyAsync(); + } + public async Task CutAsync() + { + if (!CanCut) return; + await CopyAsync(); + _connection!.DeleteSelection(); + } + public void Undo() => _connection?.Undo(); + public void Redo() => _connection?.Redo(); + public async Task PasteAsync() => await Terminal.PasteAsync(); + public void SelectAll() + { + if (IsInputEnabled) _connection?.SelectInput(); + else Terminal.SelectAll(); + } + public async Task CompleteAsync(bool backwards = false) + { + await ShowCompletionAsync(); + if (completionPopup.IsOpen && backwards) completionList.SelectedIndex = completionList.ItemCount - 1; + } + public void ApplyPreferences(UserSettings preferences) + { + this.preferences = preferences.Copy(); + _connection?.ApplyPreferences(preferences); + if (!preferences.ConsoleIntelliSense) CloseCompletion(); + } + + private void OnInputChanged(object? sender, EventArgs args) + { + if (acceptingCompletion) return; + CloseCompletion(); + if (preferences.ConsoleIntelliSense && Terminal.IsKeyboardFocusWithin && IsInputEnabled && !HasPendingInput && + EditorAnalysis.CompletionFilter(InputText, CaretOffset) is not null) + completionTimer.Start(); + } + + private void CloseCompletion() + { + completionTimer.Stop(); + completionCancellation?.Cancel(); + completionPopup.IsOpen = false; + completions = null; + } + + private async Task ObserveCompletionAsync() + { + try { await ShowCompletionAsync(); } + catch (OperationCanceledException) { } + catch (Exception error) + { + if (workbench is { IsDisposed: false } owner) await owner.ReportConsoleErrorAsync(error); + else System.Diagnostics.Trace.TraceError("Console completion failed during close: {0}", error); + } + } + + private async Task ShowCompletionAsync() + { + var connection = _connection; + if (connection is null || !IsInputEnabled || HasPendingInput || workbench?.IsDisposed != false) return; + completionCancellation?.Cancel(); + completionCancellation?.Dispose(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(preferences.IntelliSenseTimeoutSeconds)); + completionCancellation = cancellation; + var revision = connection.InputRevision; + try + { + var result = await connection.RequestCompletionAsync(cancellation.Token); + if (result is null || result.Matches.Count == 0 || revision != connection.InputRevision || + workbench.IsDisposed || !Terminal.IsKeyboardFocusWithin || !IsInputEnabled) return; + completions = result; + completionRevision = revision; + completionList.ItemsSource = result.Matches.Select(match => match.ListItemText).ToArray(); + completionList.SelectedIndex = 0; + RefreshDescription(); + var buffer = Terminal.Engine.CreateSnapshot().Buffer; + completionPopup.PlacementRect = new Rect( + Math.Clamp(buffer.CursorX * Terminal.CellSize.Width, 0, Math.Max(0, Terminal.Bounds.Width - Terminal.CellSize.Width)), + Math.Clamp(buffer.CursorY * Terminal.CellSize.Height, 0, Math.Max(0, Terminal.Bounds.Height - Terminal.CellSize.Height)), + Terminal.CellSize.Width, Terminal.CellSize.Height); + completionPopup.IsOpen = true; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + if (revision == connection.InputRevision && IsInputEnabled) + System.Diagnostics.Trace.TraceWarning("PowerShell console completion canceled or timed out."); + } + finally + { + if (ReferenceEquals(completionCancellation, cancellation)) completionCancellation = null; + } + } + + private void RefreshDescription() => completionDescription.Text = + completions is { } matches && completionList.SelectedIndex is var index && index >= 0 && index < matches.Matches.Count + ? matches.Matches[index].ToolTip : ""; + + private void AcceptCompletion() + { + if (completions is not { } matches || _connection is not { } connection || completionList.SelectedIndex < 0) return; + acceptingCompletion = true; + try { connection.ApplyCompletion(matches, completionList.SelectedIndex, completionRevision); } + finally { acceptingCompletion = false; CloseCompletion(); Terminal.Focus(); } + } + + private async void OnKeyDown(object? sender, KeyEventArgs args) + { + if (args.Handled || !IsInputEnabled || _connection is not { } connection) return; + var command = args.KeyModifiers.HasFlag(KeyModifiers.Control) || args.KeyModifiers.HasFlag(KeyModifiers.Meta); + var shift = args.KeyModifiers.HasFlag(KeyModifiers.Shift); + if (completionPopup.IsOpen) + { + if (args.Key is Key.Up or Key.Down) + { + completionList.SelectedIndex = (completionList.SelectedIndex + (args.Key == Key.Up ? -1 : 1) + completionList.ItemCount) % completionList.ItemCount; + args.Handled = true; return; + } + if (args.Key == Key.Escape) { CloseCompletion(); args.Handled = true; return; } + if (args.Key == Key.Tab || args.Key == Key.Enter && preferences.ConsoleCompletionOnEnter) + { + AcceptCompletion(); args.Handled = true; return; + } + if (args.Key == Key.Enter) CloseCompletion(); + } + if (args.Key == Key.Enter && shift) { connection.InsertNewLine(); args.Handled = true; return; } + if (shift && args.Key is Key.Left or Key.Right or Key.Home or Key.End) + { + connection.MoveCaret(args.Key is Key.Left or Key.Home ? -1 : 1, extendSelection: true, + toBoundary: args.Key is Key.Home or Key.End); + args.Handled = true; return; + } + if (!command) return; + try + { + switch (args.Key) + { + case Key.Z: if (shift) Redo(); else Undo(); break; + case Key.Y: Redo(); break; + case Key.A: SelectAll(); break; + case Key.X: await CutAsync(); break; + case Key.C when connection.SelectionLength > 0: await CopyAsync(); break; + default: return; + } + args.Handled = true; + } + catch (Exception error) + { + args.Handled = true; + if (workbench is { IsDisposed: false } owner) await owner.ReportConsoleErrorAsync(error); + } + } + public void SetFontSize(double size) + { + _fontSizeDips = size; + var points = size * 3.0 / 4; + if (_connection is not null && Math.Abs(Terminal.FontSize - points) > 0.01) + Terminal.AdjustFontSize(points - Terminal.FontSize); + } + public void ApplyAppearance(EditorTheme theme, string fontFamily) + { + _theme = theme; + _fontFamily = fontFamily; + if (_connection is null) return; + var appearance = profile.WithOverrides(new NewTerminalArgs()); + appearance.Font = new FontSettings { Face = fontFamily, Size = (_fontSizeDips ?? profile.FontSize) * 3.0 / 4, Weight = profile.FontWeight }; + Terminal.ApplyAppearance(appearance); + static uint Rgb(string value) + { + var color = Color.Parse(value); + return (uint)((color.R << 16) | (color.G << 8) | color.B); + } + Terminal.Engine.Scheme = profile.ResolveScheme() + .WithForeground(Rgb(theme.Colors["Console.Foreground"])) + .WithBackground(Rgb(theme.Colors["Console.Background"])) + .WithCursor(Rgb(theme.Colors["Console.Foreground"])); + _connection.ApplyTheme(theme); + completionBorder.Background = new SolidColorBrush(Color.Parse(theme.Colors["Console.Background"])); + completionBorder.BorderBrush = new SolidColorBrush(Color.Parse(theme.Colors["Console.Foreground"])); + completionList.Foreground = completionDescription.Foreground = new SolidColorBrush(Color.Parse(theme.Colors["Console.Foreground"])); + Terminal.InvalidateVisual(); + } + public async ValueTask DisposeAsync() + { + CloseCompletion(); + completionCancellation?.Dispose(); + if (_connection is not null) _connection.InputChanged -= OnInputChanged; + Terminal.RemoveHandler(InputElement.KeyDownEvent, OnKeyDown); + completionPopup.RemoveHandler(InputElement.KeyDownEvent, OnKeyDown); + await Terminal.CloseAsync(); + } +} +#endif diff --git a/src/Devolutions.Terminal.App/Views/IHostedTabContent.cs b/src/Devolutions.Terminal.App/Views/IHostedTabContent.cs new file mode 100644 index 0000000..adfddec --- /dev/null +++ b/src/Devolutions.Terminal.App/Views/IHostedTabContent.cs @@ -0,0 +1,17 @@ +namespace Devolutions.Terminal.App.Views; + +public interface IHostedTabContent : IAsyncDisposable +{ + bool IsDisposed { get; } + Task InitializeAsync(); + Task PrepareCloseAsync(); + void CancelClosePreparation(); + Task RequestCloseAsync(); + void FocusContent(); +} + +public interface IHostedTerminalContent : IHostedTabContent +{ + TermControl Terminal { get; } + bool IsTerminalActive => Terminal.IsKeyboardFocusWithin; +} diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs b/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs index a60eb9a..f84b57f 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs @@ -1,12 +1,64 @@ using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Devolutions.Terminal.App.Actions; namespace Devolutions.Terminal.App.Views; public partial class MainWindow { + public static async Task RequestApplicationCloseAsync(IClassicDesktopStyleApplicationLifetime desktop) + { + ArgumentNullException.ThrowIfNull(desktop); + var windows = desktop.Windows.OfType().ToArray(); + if (_applicationClosePending || windows.Any(window => window._closeConfirmationPending)) return false; + _applicationClosePending = true; + foreach (var window in windows) window._closeConfirmationPending = true; + var completed = false; + try + { + foreach (var window in windows) + if (!await window.ConfirmTabsCloseAsync(window._tabs).ConfigureAwait(true)) return false; + if (!desktop.Windows.OfType().SequenceEqual(windows)) return false; + foreach (var window in windows) + { + var layout = window.CaptureLayout(); + foreach (var content in window._tabs.Select(tab => tab.CustomContent).OfType()) + if (!await window.CompleteHostedTabCloseAsync(content).ConfigureAwait(true)) return false; + window.TryPersistCurrentLayout(layout); + window._closeApproved = true; + window.Close(); + } + completed = true; + desktop.Shutdown(); + return true; + } + catch (Exception error) + { + foreach (var window in windows.Where(window => !window._isClosed)) + window.ReportHostedTabError("Unable to quit DT", error); + return false; + } + finally + { + _applicationClosePending = false; + foreach (var window in windows) + { + window._closeConfirmationPending = false; + window._closeApproved = false; + if (!completed) + foreach (var content in window._tabs.Select(tab => tab.CustomContent).OfType()) + content.CancelClosePreparation(); + } + } + } + private void DetachPaneControls(TerminalTab tab) { + if (tab.CustomContent is { } custom) + { + DetachControl(custom); + return; + } foreach (var pane in tab.Panes.Leaves()) { DetachControl(pane.Control); @@ -64,15 +116,28 @@ private async Task ConfirmWindowCloseAsync() _closeConfirmationPending = true; try { - if (await ConfirmCloseAsync(_tabs.SelectMany(tab => tab.Panes.Leaves())).ConfigureAwait(true) && + if (await ConfirmTabsCloseAsync(_tabs).ConfigureAwait(true) && !_isClosed) { + var layout = CaptureLayout(); + foreach (var content in _tabs.Select(tab => tab.CustomContent).OfType()) + if (!await CompleteHostedTabCloseAsync(content).ConfigureAwait(true)) return; + TryPersistCurrentLayout(layout); _closeApproved = true; Close(); } } + catch (Exception error) + { + ReportHostedTabError("Unable to close window", error); + foreach (var content in _tabs.Select(tab => tab.CustomContent).OfType()) + content.CancelClosePreparation(); + } finally { + if (!_isClosed) + foreach (var content in _tabs.Select(tab => tab.CustomContent).OfType()) + content.CancelClosePreparation(); _closeConfirmationPending = false; _closeApproved = false; } @@ -80,7 +145,7 @@ private async Task ConfirmWindowCloseAsync() protected override void OnClosing(WindowClosingEventArgs e) { - if (AboutOverlay.IsVisible) + if (AboutOverlay.IsVisible && !_closeApproved) { e.Cancel = true; CloseAbout(); @@ -88,11 +153,12 @@ protected override void OnClosing(WindowClosingEventArgs e) } base.OnClosing(e); - var panes = _tabs.SelectMany(tab => tab.Panes.Leaves()).ToArray(); + var panes = _tabs.Where(tab => tab.IsTerminalTab).SelectMany(tab => tab.Panes.Leaves()).ToArray(); if (!e.Cancel && !_closeApproved && + (_tabs.Any(tab => tab.CustomContent is IHostedTabContent) || CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose, panes.Count(pane => pane.Control.IsRunning), - panes.Count(pane => pane.Control.HasUnsavedRecording))) + panes.Count(pane => pane.Control.HasUnsavedRecording)))) { e.Cancel = true; if (!_closeConfirmationPending) @@ -113,6 +179,11 @@ protected override async void OnClosed(EventArgs e) foreach (var tab in _tabs.ToArray()) { + if (tab.CustomContent is IHostedTabContent content) + { + try { await content.DisposeAsync().ConfigureAwait(true); } + catch (Exception error) { ReportHostedTabError("Unable to dispose workbench", error); } + } foreach (var pane in tab.Panes.Leaves()) { await pane.Control.CloseAsync().ConfigureAwait(true); diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs b/src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs new file mode 100644 index 0000000..3b19b58 --- /dev/null +++ b/src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs @@ -0,0 +1,131 @@ +using System.Diagnostics; +using Avalonia.Controls; +using Devolutions.Terminal.App.Connections; +using Devolutions.Terminal.App.Models; +using Devolutions.Terminal; +using Devolutions.Terminal.Core; +using Devolutions.Terminal.Settings; + +namespace Devolutions.Terminal.App.Views; + +public partial class MainWindow +{ + public static bool SupportsPowerShellIse => +#if POWERSHELL_ISE + true; +#else + false; +#endif + + private async Task CreatePowerShellIseTabAsync( + ProfileSettings profile, TabLayoutDescriptor? restored = null, bool regenerateIdentities = false) + { +#if POWERSHELL_ISE + if (_isClosed || _closeConfirmationPending || _applicationClosePending) + throw new InvalidOperationException("Cannot open Iseberg while DT is closing."); + await PowerShellIseRuntime.InitializeAsync().ConfigureAwait(true); + return await CreatePowerShellIseTabCoreAsync(profile, restored, regenerateIdentities).ConfigureAwait(true); +#else + await Task.CompletedTask; + throw new NotSupportedException("This terminal-only build does not include Iseberg. Use an Iseberg-enabled managed DT distribution."); +#endif + } + +#if POWERSHELL_ISE + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private async Task CreatePowerShellIseTabCoreAsync( + ProfileSettings profile, TabLayoutDescriptor? restored, bool regenerateIdentities) + { + if (_isClosed || _closeConfirmationPending || _applicationClosePending) + throw new InvalidOperationException("Cannot open Iseberg while DT is closing."); + PowerShellIseTab.ValidateProfile(profile); + var session = restored?.Root.Session is { } saved ? CloneSession(saved) : CreateSessionDescriptor(profile); + if (regenerateIdentities) session.SessionId = Guid.NewGuid(); + if (restored is null && !regenerateIdentities) + { + var recovered = await PowerShellIseWorkspaceCatalog.FindRecoverableAsync( + Path.Combine(SettingsService.SettingsDirectory, "PowerShellIse"), profile.Guid); + if (recovered is { } workspaceId) session.SessionId = workspaceId; + } + var pane = CreatePane(profile, session); + var tab = new TerminalTab( + restored is not null && !regenerateIdentities ? restored.TabId : Guid.NewGuid(), + new Panes.PaneTree(pane)) + { + Title = restored?.Title ?? pane.Title, + CustomTitle = restored?.CustomTitle, + Color = restored?.Color ?? profile.TabColor, + }; + var content = new PowerShellIseTab(profile, pane.Control, BuildPaneLeaf(tab, pane), session.SessionId); + tab.CustomContent = content; + content.Workbench.CloseRequested += async (_, _) => await CloseTabAsync(tab).ConfigureAwait(true); + content.Workbench.ErrorOccurred += (_, error) => ReportHostedTabError(error.Title, error.Exception); + content.Workbench.OptionsRequested += (_, _) => OpenSettingsTab(profile.Guid); + _tabCollection.Add(tab); + ActivateTab(tab); + try + { + await content.InitializeAsync().ConfigureAwait(true); + if (ReferenceEquals(_activeTab, tab)) content.FocusContent(); + return tab; + } + catch + { + try { await content.DisposeAsync().ConfigureAwait(true); } + finally + { + _tabCollection.Remove(tab); + DetachControl(content); + if (_tabCollection.ActiveTab is { } replacement) ActivateTab(replacement); + else { _activeTab = null; RebuildTabs(); } + } + throw; + } + } +#endif + + private void ReportHostedTabError(string title, Exception error) + { + Trace.TraceError("{0}: {1}", title, error); + if (!_isClosed) ShowNotification(new TerminalNotification(title, error.Message)); + } + + private async Task CompleteHostedTabCloseAsync(IHostedTabContent content) + { + try { return await content.RequestCloseAsync().ConfigureAwait(true); } + catch (Exception error) + { + ReportHostedTabError("Unable to close Iseberg cleanly", error); + // Disposal failures must not leave an unusable, already-disposed tab attached. + return content.IsDisposed; + } + } + + private async Task ConfirmTabsCloseAsync(IEnumerable tabs, bool automaticExit = false) + { + var snapshot = tabs.ToArray(); + var hosted = snapshot.Select(tab => tab.CustomContent).OfType().ToArray(); + var approved = false; + try + { + if (!await ConfirmCloseAsync(snapshot.Where(tab => tab.IsTerminalTab || + tab.CustomContent is IHostedTerminalContent { Terminal.HasUnsavedRecording: true }) + .SelectMany(tab => tab.Panes.Leaves()), automaticExit).ConfigureAwait(true)) + return false; + foreach (var content in hosted) + if (!await content.PrepareCloseAsync().ConfigureAwait(true)) return false; + approved = snapshot.All(tab => _tabs.Contains(tab) && !tab.IsClosing); + return approved; + } + catch (Exception error) + { + ReportHostedTabError("Unable to close workbench", error); + return false; + } + finally + { + if (!approved) + foreach (var content in hosted) content.CancelClosePreparation(); + } + } +} diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs index 888512c..95e9f5d 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs @@ -48,6 +48,7 @@ public partial class MainWindow : private readonly TerminalConfirmationDialog _confirmationDialog = new(); private bool _closeApproved; private bool _closeConfirmationPending; + private static bool _applicationClosePending; private readonly ApplicationStateStore _stateStore; private readonly TerminalConnectionFactory _connectionFactory; private readonly DynamicProfileManager _dynamicProfileManager; @@ -601,11 +602,17 @@ private void OpenNewWindow(INewContentArgs? content) /// Alt splits the current pane, Shift opens a new window, and Ctrl opens elevated (Windows only). /// private async Task LaunchProfileAsync(ProfileSettings profile, KeyModifiers modifiers) + { + try { await LaunchProfileCoreAsync(profile, modifiers).ConfigureAwait(true); } + catch (Exception error) { ReportHostedTabError("Unable to launch profile", error); } + } + + private async Task LaunchProfileCoreAsync(ProfileSettings profile, KeyModifiers modifiers) { var elevate = OperatingSystem.IsWindows() && modifiers.HasFlag(KeyModifiers.Control); var effectiveProfile = NewTabMenuResolver.ForMenuLaunch(profile, elevate); - if (modifiers.HasFlag(KeyModifiers.Alt)) + if (modifiers.HasFlag(KeyModifiers.Alt) && effectiveProfile.Kind == ProfileKind.Terminal) { await SplitActivePaneAsync(PaneSplitOrientation.Vertical, effectiveProfile).ConfigureAwait(true); return; @@ -624,6 +631,24 @@ private async Task LaunchProfileAsync(ProfileSettings profile, KeyModifiers modi private async Task CreateTabAsync(ProfileSettings profile) { + if (_isClosed || _closeConfirmationPending || _applicationClosePending) + throw new InvalidOperationException("Cannot open a tab while the window is preparing to close."); + if (profile.Kind != ProfileKind.Terminal) + { + try + { + if (profile.Kind != ProfileKind.PowerShellIse) + throw new NotSupportedException($"Profile '{profile.Name}' has an unsupported type."); + await CreatePowerShellIseTabAsync(profile).ConfigureAwait(true); + } + catch (Exception error) + { + ReportHostedTabError("Unable to launch Iseberg", error); + throw; + } + return; + } + TerminalPane? pane = null; TerminalTab? tab = null; try @@ -1114,12 +1139,17 @@ private async Task SplitActivePaneAsync( { var tab = _activeTab; var activePane = tab?.Panes.ActiveContent; - if (tab is null || tab.IsSettingsTab || activePane is null || tab.IsClosing) + if (tab is null || !tab.IsTerminalTab || activePane is null || tab.IsClosing) { return; } var paneProfile = profile ?? activePane.Profile; + if (paneProfile.Kind != ProfileKind.Terminal) + { + await CreateTabAsync(paneProfile).ConfigureAwait(true); + return; + } TerminalPane? newPane = null; try { @@ -1162,10 +1192,11 @@ private void ActivateTab(TerminalTab tab) SynchronizeTitle(tab); RebuildTabs(); RebuildTerminalHost(); - if (tab.IsSettingsTab) + if (tab.CustomContent is not null) { UpdateRecordingStateBar(); - tab.CustomContent?.Focus(); + if (tab.CustomContent is IHostedTabContent hosted) hosted.FocusContent(); + else tab.CustomContent.Focus(); return; } @@ -1224,13 +1255,17 @@ private async Task CloseTabAsync( return; } - if ((!confirmed && !await ConfirmCloseAsync(tab.Panes.Leaves(), automaticExit).ConfigureAwait(true)) || + if ((!confirmed && !await ConfirmTabsCloseAsync([tab], automaticExit).ConfigureAwait(true)) || tab.IsClosing) { return; } var finalLayout = _tabs.Count == 1 ? CaptureLayout() : null; + if (tab.CustomContent is IHostedTabContent hosted) + { + if (!await CompleteHostedTabCloseAsync(hosted).ConfigureAwait(true)) return; + } tab.IsClosing = true; var wasActive = ReferenceEquals(_activeTab, tab); DetachPaneControls(tab); @@ -1621,6 +1656,9 @@ ArgumentException or return new TextBlock { Text = icon, + FontFamily = icon == "\uE943" + ? new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets") + : FontFamily.Default, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, }; @@ -1689,11 +1727,12 @@ private ContextMenu CreateTabContextMenu(TerminalTab tab) => { Header = "Duplicate", IsEnabled = !tab.IsSettingsTab, - Command = new RelayCommand(() => + Command = new RelayCommand(async () => { if (!tab.IsSettingsTab) { - _ = RestoreTabAsync(CaptureTab(tab), regenerateIdentities: true); + try { await RestoreTabAsync(CaptureTab(tab), regenerateIdentities: true).ConfigureAwait(true); } + catch (Exception error) { ReportHostedTabError("Unable to duplicate tab", error); } } }), }, @@ -1766,7 +1805,7 @@ private void EndTabDrag(TerminalTab tab, Control control, PointerReleasedEventAr return; } - if (!tab.IsSettingsTab && + if (tab.IsTerminalTab && (position.Y < -24 || position.Y > TabStrip.Bounds.Height + 24)) { var local = e.GetPosition(this); @@ -1835,9 +1874,10 @@ private Button CreateCloseButton(TerminalTab tab) return close; } - private TermControl? ActiveControl => _activeTab is { IsSettingsTab: false } terminalTab + private TermControl? ActiveControl => _activeTab is { IsTerminalTab: true } terminalTab ? terminalTab.Panes.ActiveContent?.Control - : null; + : _activeTab?.CustomContent is IHostedTerminalContent hosted && hosted.IsTerminalActive + ? hosted.Terminal : null; private void ConfigureActionDispatcher() { @@ -2110,7 +2150,7 @@ await SplitActivePaneAsync( async _ => await ClosePaneAsync(_activeTab!, _activeTab!.Panes.ActiveContent!).ConfigureAwait(true)); Register(ShortcutAction.CloseOtherPanes, ActionScope.Pane, _ => _activeTab?.Panes.Count > 1, async _ => await CloseOtherPanesAsync().ConfigureAwait(true)); - Register(ShortcutAction.TogglePaneZoom, ActionScope.Pane, _ => _activeTab?.Panes.ActiveContent is not null, _ => + Register(ShortcutAction.TogglePaneZoom, ActionScope.Pane, _ => ActiveControl is not null, _ => { _activeTab!.Panes.ToggleZoom(); RebuildTerminalHost(); @@ -2129,7 +2169,7 @@ await SplitActivePaneAsync( return Task.CompletedTask; }); Register(ShortcutAction.ResizePane, ActionScope.Pane, - action => _activeTab?.Panes.ActiveContent is not null && action.Args is ResizePaneArgs, + action => ActiveControl is not null && action.Args is ResizePaneArgs, action => { var direction = ToPaneDirection(((ResizePaneArgs)action.Args!).ResizeDirection); @@ -2169,7 +2209,7 @@ action.Args is SwapPaneArgs args && ActivatePane(_activeTab, pane); return Task.CompletedTask; }); - Register(ShortcutAction.RestartConnection, ActionScope.Pane, _ => ActiveControl is not null, + Register(ShortcutAction.RestartConnection, ActionScope.Pane, _ => _activeTab?.IsTerminalTab == true && ActiveControl is not null, async _ => await ActiveControl!.RestartAsync().ConfigureAwait(true)); Register(ShortcutAction.TogglePaneReadOnly, ActionScope.Pane, _ => ActiveControl is not null, _ => { @@ -2530,10 +2570,10 @@ args with Close(); return Task.CompletedTask; }); - Register(ShortcutAction.Quit, ActionScope.Application, _ => true, _ => + Register(ShortcutAction.Quit, ActionScope.Application, _ => true, async _ => { - (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); - return Task.CompletedTask; + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + await RequestApplicationCloseAsync(desktop).ConfigureAwait(true); }); Register(ShortcutAction.ToggleFullscreen, ActionScope.Window, _ => true, _ => { @@ -2702,7 +2742,7 @@ private async Task CloseOtherTabsAsync(uint? index) { var keep = ResolveTab(index) ?? _activeTab; var closing = _tabs.Where(tab => !ReferenceEquals(tab, keep)).ToArray(); - if (!await ConfirmCloseAsync(closing.SelectMany(tab => tab.Panes.Leaves())).ConfigureAwait(true)) + if (!await ConfirmTabsCloseAsync(closing).ConfigureAwait(true)) { return; } @@ -2710,6 +2750,12 @@ private async Task CloseOtherTabsAsync(uint? index) foreach (var tab in closing) { await CloseTabAsync(tab, confirmed: true).ConfigureAwait(true); + if (_tabs.Contains(tab)) + { + foreach (var remaining in closing.Select(candidate => candidate.CustomContent).OfType()) + remaining.CancelClosePreparation(); + break; + } } if (keep is not null) @@ -2723,7 +2769,7 @@ private async Task CloseTabsAfterAsync(uint? index) var keep = ResolveTab(index) ?? _activeTab; var keepIndex = keep is null ? -1 : TabIndexOf(keep); var closing = _tabs.Skip(keepIndex + 1).ToArray(); - if (!await ConfirmCloseAsync(closing.SelectMany(tab => tab.Panes.Leaves())).ConfigureAwait(true)) + if (!await ConfirmTabsCloseAsync(closing).ConfigureAwait(true)) { return; } @@ -2731,6 +2777,12 @@ private async Task CloseTabsAfterAsync(uint? index) foreach (var tab in closing) { await CloseTabAsync(tab, confirmed: true).ConfigureAwait(true); + if (_tabs.Contains(tab)) + { + foreach (var remaining in closing.Select(candidate => candidate.CustomContent).OfType()) + remaining.CancelClosePreparation(); + break; + } } } @@ -2757,7 +2809,7 @@ private async Task CloseOtherPanesAsync() private bool CanMoveFocus(MoveFocusArgs? args) { - if (_activeTab?.Panes.ActiveContent is null || args is null) + if (_activeTab is not { IsTerminalTab: true } || _activeTab.Panes.ActiveContent is null || args is null) { return false; } @@ -2792,9 +2844,9 @@ private void MoveFocus(MoveFocusArgs args) private bool CanMovePane(ActionAndArgs action) => action.Args is MovePaneArgs args && string.IsNullOrEmpty(args.Window) && - _activeTab is { IsSettingsTab: false } && + _activeTab is { IsTerminalTab: true } && _activeTab.Panes.ActiveContent is not null && - ResolveTab(args.TabIndex) is { IsSettingsTab: false } target && + ResolveTab(args.TabIndex) is { IsTerminalTab: true } target && !ReferenceEquals(target, _activeTab); private void MovePane(MovePaneArgs args) @@ -3117,7 +3169,8 @@ private async Task ExecuteSelectedPaletteAsync() if (CommandPaletteList.SelectedItem is PaletteItem item) { CloseCommandPalette(); - await item.Execute().ConfigureAwait(true); + try { await item.Execute().ConfigureAwait(true); } + catch (Exception error) { ReportHostedTabError("Command failed", error); } } } @@ -3235,7 +3288,7 @@ private async Task PasteCoordinatedAsync() var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; var text = clipboard is null ? null : await clipboard.TryGetTextAsync().ConfigureAwait(true); if (tab is null || - tab.IsSettingsTab || + !tab.IsTerminalTab || activePane is null || !_tabs.Contains(tab) || string.IsNullOrEmpty(text)) @@ -3608,7 +3661,7 @@ private string GetDefaultRecordingPath() } private bool CanPaste() => - _activeTab is { IsSettingsTab: false } && + _activeTab is { IsTerminalTab: true } && _activeTab.Panes.ActiveContent is { } activePane && _activeTab.BroadcastInput.ResolveTargets(activePane, _activeTab.Panes.Leaves()).Count > 0; @@ -3870,19 +3923,24 @@ private void CaptureNormalWindowBounds() }; } - private void OpenSettingsTab() + private void OpenSettingsTab(string? profileGuid = null) { var existing = _tabs.FirstOrDefault(static tab => tab.IsSettingsTab); if (existing is not null) { ActivateTab(existing); + if (profileGuid is not null && existing.CustomContent is SettingsView settingsView && + settingsView.DataContext is SettingsEditorViewModel editor) editor.SelectProfile(profileGuid); return; } var view = SettingsViewFactory.CreateView( () => SettingsService.LoadWithDynamicProfiles(_dynamicProfileManager), SaveSettingsAndRefresh, - SettingsService.CreateDefault); + SettingsService.CreateDefault, + supportsPowerShellIse: SupportsPowerShellIse); + if (profileGuid is not null && view.DataContext is SettingsEditorViewModel viewModel) + viewModel.SelectProfile(profileGuid); var profile = new ProfileSettings { Name = "Settings", @@ -4200,6 +4258,32 @@ private async Task RestoreTabAsync( bool regenerateIdentities = false) { ArgumentNullException.ThrowIfNull(descriptor); + if (_isClosed || _closeConfirmationPending || _applicationClosePending) + throw new InvalidOperationException("Cannot restore a tab while DT is closing."); + if (descriptor.Root.Session is { Kind: ProfileKind.PowerShellIse } iseSession) + { + var profile = _settings.Profiles.FirstOrDefault(candidate => + !string.IsNullOrEmpty(iseSession.ProfileId) && candidate.Guid == iseSession.ProfileId) ?? + _settings.Profiles.FirstOrDefault(candidate => candidate.Name == iseSession.ProfileName) ?? + new ProfileSettings + { + Guid = iseSession.ProfileId, + Name = iseSession.ProfileName, + Kind = ProfileKind.PowerShellIse, + Icon = "\uE943", + }; + profile = profile.WithOverrides(new NewTerminalArgs( + StartingDirectory: iseSession.StartingDirectory, + TabTitle: iseSession.TabTitle ?? string.Empty, + TabColor: iseSession.TabColor, + Elevate: iseSession.Elevate)); + profile.Kind = ProfileKind.PowerShellIse; + profile.Commandline = string.Empty; + profile.IseLoadProfiles = iseSession.IseLoadProfiles; + profile.IseColorTheme = iseSession.IseColorTheme; + return await CreatePowerShellIseTabAsync(profile, descriptor, regenerateIdentities).ConfigureAwait(true); + } + var sessions = new Dictionary(); var root = RestorePaneNode(descriptor.Root, sessions, regenerateIdentities); var activePane = sessions.GetValueOrDefault(descriptor.ActiveSessionId) ?? sessions.Values.First(); @@ -4261,6 +4345,9 @@ private PaneNode RestorePaneNode( SuppressApplicationTitle: session.SuppressApplicationTitle, Elevate: session.Elevate, ReloadEnvironmentVariables: session.ReloadEnvironmentVariables)); + profile.Kind = session.Kind; + profile.IseLoadProfiles = session.IseLoadProfiles; + profile.IseColorTheme = session.IseColorTheme; var pane = CreatePane(profile, session, ClonePresentation(descriptor.Presentation)); sessions.Add(savedSession.SessionId, pane); @@ -4282,6 +4369,9 @@ private PaneNode RestorePaneNode( private static TerminalSessionDescriptor CreateSessionDescriptor(ProfileSettings profile) => new() { + Kind = profile.Kind, + IseLoadProfiles = profile.IseLoadProfiles, + IseColorTheme = profile.IseColorTheme, ProfileId = profile.Guid, ProfileName = profile.Name, Commandline = profile.Commandline, @@ -4297,6 +4387,9 @@ private static TerminalSessionDescriptor CreateSessionDescriptor(ProfileSettings private static TerminalSessionDescriptor CloneSession(TerminalSessionDescriptor session) => new() { + Kind = session.Kind, + IseLoadProfiles = session.IseLoadProfiles, + IseColorTheme = session.IseColorTheme, SessionId = session.SessionId, ProfileId = session.ProfileId, ProfileName = session.ProfileName, @@ -5072,9 +5165,13 @@ public static string Icon(ProfileSettings profile) { if (profile.IconResource?.ToString() is { Length: > 0 } icon) { - return icon; + return profile.Kind == ProfileKind.PowerShellIse && icon == "\uE943" + ? ProfileSettings.PowerShellIseIcon : icon; } + if (profile.Kind == ProfileKind.PowerShellIse) + return ProfileSettings.PowerShellIseIcon; + if (profile.Commandline.Contains("pwsh.exe", StringComparison.OrdinalIgnoreCase)) { return "ms-appx:///ProfileIcons/pwsh.png"; @@ -5170,7 +5267,8 @@ public TerminalTab(TerminalPane initialPane) public Control? CustomContent { get; set; } - public bool IsSettingsTab => CustomContent is not null; + public bool IsSettingsTab => CustomContent is not null && CustomContent is not IHostedTabContent; + public bool IsTerminalTab => CustomContent is null; public TerminalTab(Guid id, PaneTree panes) { diff --git a/src/Devolutions.Terminal.App/Views/PowerShellIseTab.cs b/src/Devolutions.Terminal.App/Views/PowerShellIseTab.cs new file mode 100644 index 0000000..9d659f2 --- /dev/null +++ b/src/Devolutions.Terminal.App/Views/PowerShellIseTab.cs @@ -0,0 +1,147 @@ +#if POWERSHELL_ISE +using Avalonia.Controls; +using Avalonia.Styling; +using Devolutions.Terminal.Settings; +using Iseberg; +using Iseberg.Core; + +namespace Devolutions.Terminal.App.Views; + +public sealed class PowerShellIseTab : Decorator, IHostedTerminalContent +{ + private readonly string? profileId; + private Task? initializationTask; + public PowerShellIseTab(ProfileSettings profile, TermControl? terminal = null, Control? terminalView = null, + Guid? workspaceId = null, string? stateDirectory = null) + { + ValidateProfile(profile); + profileId = profile.Guid; + var themeName = IsebergThemes.Canonicalize(profile.IseColorTheme); + WorkspaceId = workspaceId ?? Guid.NewGuid(); + if (WorkspaceId == Guid.Empty) throw new ArgumentException("A nonempty workspace identity is required.", nameof(workspaceId)); + var root = stateDirectory ?? Path.Combine(SettingsService.SettingsDirectory, "PowerShellIse"); + if (!Path.IsPathFullyQualified(root)) + throw new ArgumentException("An absolute state directory is required.", nameof(stateDirectory)); + StateDirectory = Path.Combine(root, "Workspaces", WorkspaceId.ToString("N")); + + Terminal = terminal ?? new TermControl(); + var console = new DtIsebergConsole(Terminal, terminalView ?? Terminal, profile); + WorkbenchControl? control = null; + Workbench = control = new WorkbenchControl(new WorkbenchOptions + { + Console = console, + ThemeProvider = dark => ResolveTheme(control?.SelectedThemePreset ?? themeName, dark), + ThemeChoices = EditorThemePresets.OriginalBuiltIns().Concat( + new[] { EditorThemePresets.Classic(), EditorThemePresets.Dark(), EditorThemePresets.Light(), + new EditorTheme { Name = IsebergThemes.FollowDt } }).ToArray(), + EnablePersistence = true, + SettingsPath = Path.Combine(StateDirectory, "settings.json"), + ShowSessionTabs = false, + FontSizesInDips = true, + EnableUpdateChecks = false, + EnableCommandsPane = true, + EnableDebuggerPane = true, + StartingDirectory = profile.ExpandStartingDirectory(), + SnippetDirectory = Path.Combine(root, "Snippets"), + ApplyHostPreferences = settings => + { + settings.ThemePreset = themeName; + settings.FontFamily = profile.FontFace; + settings.FontSize = profile.FontSize; + settings.LoadProfiles = profile.IseLoadProfiles; + settings.ShowLineNumbers = profile.IseShowLineNumbers; + settings.WordWrap = profile.IseWordWrap; + settings.PromptToSaveBeforeRun = profile.IsePromptToSaveBeforeRun; + settings.AutoSaveMinutes = profile.IseAutoSaveMinutes; + settings.ConsoleIntelliSense = profile.IseConsoleIntelliSense; + settings.ConsoleCompletionOnEnter = profile.IseConsoleCompletionOnEnter; + settings.ScriptIntelliSense = profile.IseScriptIntelliSense; + settings.ScriptCompletionOnEnter = profile.IseScriptCompletionOnEnter; + settings.IntelliSenseTimeoutSeconds = profile.IseIntelliSenseTimeoutSeconds; + settings.ShowOutlining = profile.IseShowOutlining; + settings.WarnDuplicateFiles = profile.IseWarnDuplicateFiles; + settings.UseLocalHelp = profile.IseUseLocalHelp; + settings.UseDefaultSnippets = profile.IseUseDefaultSnippets; + settings.RecentFileCount = profile.IseRecentFileCount; + settings.ShowToolbar = profile.IseShowToolbar; + }, + Preferences = new UserSettings + { + ThemePreset = themeName, + LoadProfiles = profile.IseLoadProfiles, + CheckForUpdates = false, + ShowCommands = false, + FontFamily = profile.FontFace, + FontSize = profile.FontSize, + ShowLineNumbers = profile.IseShowLineNumbers, + WordWrap = profile.IseWordWrap, + PromptToSaveBeforeRun = profile.IsePromptToSaveBeforeRun, + AutoSaveMinutes = profile.IseAutoSaveMinutes, + }, + }); + var scope = new ThemeVariantScope + { + RequestedThemeVariant = themeName == IsebergThemes.FollowDt ? ThemeVariant.Default : + ResolveTheme(themeName, false).Colors["Script.Background"] is "#012456" or "#000000" or "#1E1E1E" + ? ThemeVariant.Dark : ThemeVariant.Light, + Child = Workbench, + }; + Child = scope; + Workbench.ThemeSelectionChanged += (_, _) => + { + var selected = Workbench.SelectedThemePreset ?? themeName; + scope.RequestedThemeVariant = selected == IsebergThemes.FollowDt ? ThemeVariant.Default : + ResolveTheme(selected, false).Colors["Script.Background"] is "#012456" or "#000000" or "#1E1E1E" + ? ThemeVariant.Dark : ThemeVariant.Light; + }; + } + + internal static void ValidateProfile(ProfileSettings profile) + { + ArgumentNullException.ThrowIfNull(profile); + if (profile.Kind != ProfileKind.PowerShellIse) + throw new ArgumentException("A PowerShell ISE profile is required.", nameof(profile)); + if (!IsebergThemes.IsSupported(IsebergThemes.Canonicalize(profile.IseColorTheme))) + throw new NotSupportedException($"Unsupported Iseberg color theme '{profile.IseColorTheme}'. Select a supported theme in profile settings."); + if (profile.Elevate) + throw new NotSupportedException("PowerShell ISE runs with DT's permissions. Start DT as administrator to use an elevated workbench."); + if (!string.IsNullOrWhiteSpace(profile.Commandline) || profile.ConnectionType is not null || profile.Environment.Count > 0) + throw new NotSupportedException("PowerShell ISE profiles do not support terminal command lines, connection types, or process environment overrides."); + } + + public static EditorTheme ResolveTheme(string name, bool dark) => IsebergThemes.Canonicalize(name) switch + { + IsebergThemes.Classic => EditorThemePresets.Classic(), + IsebergThemes.Dark => EditorThemePresets.Dark(), + IsebergThemes.Light => EditorThemePresets.Light(), + IsebergThemes.FollowDt => dark ? EditorThemePresets.Dark() : EditorThemePresets.Light(), + var original when EditorThemePresets.OriginalNames.Contains(original) => EditorThemePresets.Original(original), + _ => throw new NotSupportedException($"Unsupported Iseberg color theme '{name}'.") + }; + + public WorkbenchControl Workbench { get; } + public Guid WorkspaceId { get; } + public string StateDirectory { get; } + public TermControl Terminal { get; } + public bool IsTerminalActive => Workbench.IsNativeConsoleActive; + public bool IsDisposed => Workbench.IsDisposed; + public Task InitializeAsync() + { + var initialization = Workbench.InitializeAsync(); + return initializationTask ??= InitializeCoreAsync(initialization); + } + private async Task InitializeCoreAsync(Task initialization) + { + await initialization; + await PowerShellIseWorkspaceCatalog.RecordProfileAsync(StateDirectory, profileId); + } + public Task PrepareCloseAsync() => Workbench.PrepareCloseAsync(); + public void CancelClosePreparation() => Workbench.CancelClosePreparation(); + public Task RequestCloseAsync() => Workbench.RequestCloseAsync(); + public ValueTask DisposeAsync() => Workbench.DisposeAsync(); + public void FocusContent() + { + if (Workbench.IsStarted) Workbench.ScriptEditorView.FocusEditor(); + } +} +#endif diff --git a/src/Devolutions.Terminal.App/Views/PowerShellIseWorkspaceCatalog.cs b/src/Devolutions.Terminal.App/Views/PowerShellIseWorkspaceCatalog.cs new file mode 100644 index 0000000..4fd04ad --- /dev/null +++ b/src/Devolutions.Terminal.App/Views/PowerShellIseWorkspaceCatalog.cs @@ -0,0 +1,81 @@ +#if POWERSHELL_ISE +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using Iseberg.Core; + +namespace Devolutions.Terminal.App.Views; + +public static partial class PowerShellIseWorkspaceCatalog +{ + public static async Task RecordProfileAsync(string workspaceDirectory, string? profileId) + { + if (!Path.IsPathFullyQualified(workspaceDirectory)) throw new ArgumentException("An absolute workspace directory is required.", nameof(workspaceDirectory)); + if (string.IsNullOrWhiteSpace(profileId)) return; + var path = Path.Combine(workspaceDirectory, "profile.json"); + if (File.Exists(path)) + { + var ownerProfile = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)) + ?? throw new InvalidDataException($"Empty ISE profile identity: {path}"); + if (!string.Equals(ownerProfile, profileId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("This ISE workspace belongs to a different profile."); + return; + } + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(profileId)); + if (OperatingSystem.IsWindows()) File.Move(temporary, path, overwrite: false); + else + { + // Unix File.Move can race through an overwriting rename; link publishes without replacing an owner. + var result = OperatingSystem.IsMacOS() ? LinkMacOS(temporary, path) : LinkUnix(temporary, path); + if (result != 0) + { + var error = Marshal.GetLastPInvokeError(); + throw new IOException($"Could not publish ISE profile identity '{path}': {new System.ComponentModel.Win32Exception(error).Message}", + unchecked((int)0x80070000) | error); + } + } + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + + [LibraryImport("libc", EntryPoint = "link", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int LinkUnix(string source, string destination); + + [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "link", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int LinkMacOS(string source, string destination); + + /// Finds one abandoned dirty workspace for the same profile; never consumes another live tab. + public static async Task FindRecoverableAsync(string stateRoot, string? profileId) + { + if (!Path.IsPathFullyQualified(stateRoot)) throw new ArgumentException("An absolute state root is required.", nameof(stateRoot)); + if (string.IsNullOrWhiteSpace(profileId)) return null; + var workspaces = Path.Combine(stateRoot, "Workspaces"); + if (!Directory.Exists(workspaces)) return null; + foreach (var directory in Directory.EnumerateDirectories(workspaces).OrderByDescending(Directory.GetLastWriteTimeUtc)) + { + if (!Guid.TryParseExact(Path.GetFileName(directory), "N", out var id)) continue; + var marker = Path.Combine(directory, "profile.json"); + if (!File.Exists(marker)) continue; + var ownerProfile = JsonSerializer.Deserialize(await File.ReadAllTextAsync(marker)) + ?? throw new InvalidDataException($"Empty ISE profile identity: {marker}"); + if (!string.Equals(ownerProfile, profileId, StringComparison.OrdinalIgnoreCase)) continue; + var settings = Path.Combine(directory, "settings.json"); + var state = await new WorkbenchStateStore(settings + ".workbench.json").LoadAsync(); + if (state is null) continue; + WorkbenchPersistenceLease lease; + try { lease = new(settings); } + catch (IOException error) when ((error.HResult & 0xffff) is 32 or 33 or 11 or 35) + { + Trace.TraceInformation("ISE recovery workspace is still owned: {0}", directory); + continue; + } + using (lease) + if ((await new ScriptRecovery(settings + ".recovery").ReadAsync()).Count > 0) return id; + } + return null; + } +} +#endif diff --git a/src/Devolutions.Terminal.Browser.Host/HostProfileCatalog.cs b/src/Devolutions.Terminal.Browser.Host/HostProfileCatalog.cs index aeb8248..5d7af14 100644 --- a/src/Devolutions.Terminal.Browser.Host/HostProfileCatalog.cs +++ b/src/Devolutions.Terminal.Browser.Host/HostProfileCatalog.cs @@ -116,6 +116,12 @@ public HostSessionBroker CreateBroker() => private static bool IsLaunchable(ProfileSettings profile, string commandLine, out string? reason) { + if (profile.Kind != ProfileKind.Terminal) + { + reason = "This profile requires the desktop PowerShell ISE workbench."; + return false; + } + if (Guid.TryParse(profile.ConnectionType, out var connectionType) && connectionType == AzureCloudShellConnection.ConnectionTypeGuid) { diff --git a/src/Devolutions.Terminal.Connection/ConPtyHost.targets b/src/Devolutions.Terminal.Connection/ConPtyHost.targets index dc8c81d..d4e4fbb 100644 --- a/src/Devolutions.Terminal.Connection/ConPtyHost.targets +++ b/src/Devolutions.Terminal.Connection/ConPtyHost.targets @@ -28,6 +28,7 @@ CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> diff --git a/src/Devolutions.Terminal.Control/TermControl.cs b/src/Devolutions.Terminal.Control/TermControl.cs index 461e108..ddd1644 100644 --- a/src/Devolutions.Terminal.Control/TermControl.cs +++ b/src/Devolutions.Terminal.Control/TermControl.cs @@ -938,6 +938,19 @@ public void AdjustFontSize(double delta) InvalidateVisual(); } + public void ApplyAppearance(ProfileSettings profile) + { + ArgumentNullException.ThrowIfNull(profile); + ObjectDisposedException.ThrowIf(_rendererDisposed, this); + Profile = profile; + _padding = ParsePadding(profile.Padding); + _defaultFontSize = profile.FontSize <= 0 ? 12 : profile.FontSize; + ConfigureRenderer(profile); + if (VisualRoot is not null) MeasureGlyph(); + InvalidateMeasure(); + InvalidateVisual(); + } + public void ResetFontSize() { _fontSize = _defaultFontSize; diff --git a/src/Devolutions.Terminal.Ise/Directory.Build.props b/src/Devolutions.Terminal.Ise/Directory.Build.props new file mode 100644 index 0000000..75b57e4 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Directory.Build.props @@ -0,0 +1,10 @@ + + + + false + false + false + false + false + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/AnsiOutputParser.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/AnsiOutputParser.cs new file mode 100644 index 0000000..9f98995 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/AnsiOutputParser.cs @@ -0,0 +1,148 @@ +using System.Globalization; +using System.Text; + +namespace Iseberg.Core; + +// Streaming SGR only: cursor/erase/OSC controls never mutate the protected transcript. +public sealed class AnsiOutputParser +{ + private string pending = ""; + private string? foreground; + private string? background; + private bool bold; + private bool underline; + private bool inverse; + public bool UnsupportedControl { get; private set; } + public void Complete() + { + if (pending.Length == 0) return; + pending = ""; + UnsupportedControl = true; + } + + private static readonly string[] Palette = + [ + "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#C0C0C0", + "#808080", "#FF0000", "#00FF00", "#FFFF00", "#0000FF", "#FF00FF", "#00FFFF", "#FFFFFF" + ]; + + public static string ConsoleColorHex(ConsoleColor color) + { + if (!Enum.IsDefined(color)) throw new ArgumentOutOfRangeException(nameof(color)); + return Palette[(int)color switch + { + 1 => 4, 3 => 6, 4 => 1, 6 => 3, 9 => 12, 11 => 14, 12 => 9, 14 => 11, var value => value + }]; + } + + public IReadOnlyList Parse(OutputEntry entry) + { + var text = pending + entry.Text; + pending = ""; + var output = new List(); + var content = new StringBuilder(); + void Flush() + { + if (content.Length == 0) return; + var fg = foreground ?? entry.Style?.Foreground; + var bg = background ?? entry.Style?.Background; + var style = new OutputStyle(fg, bg, bold || entry.Style?.Bold == true, underline || entry.Style?.Underline == true, inverse); + output.Add(entry with { Text = content.ToString(), Style = style == new OutputStyle() ? null : style }); + content.Clear(); + } + for (var index = 0; index < text.Length; index++) + { + if (text[index] != '\x1b') + { + if (text[index] == '\b') UnsupportedControl = true; + else content.Append(text[index]); + continue; + } + Flush(); + var start = index; + if (++index >= text.Length) { pending = text[start..]; break; } + if (text[index] == '[') + { + index++; + while (index < text.Length && text[index] is not (>= '@' and <= '~')) index++; + if (index >= text.Length) + { + if (text.Length - start <= 4096) pending = text[start..]; + else UnsupportedControl = true; + break; + } + if (text[index] == 'm') Apply(text[(start + 2)..index]); + else UnsupportedControl = true; + } + else if (text[index] == ']') + { + while (++index < text.Length && text[index] != '\a' && + !(text[index] == '\x1b' && index + 1 < text.Length && text[index + 1] == '\\')) { } + if (index >= text.Length) + { + if (text.Length - start <= 4096) pending = text[start..]; + else UnsupportedControl = true; + break; + } + if (text[index] == '\x1b') index++; + UnsupportedControl = true; + } + else UnsupportedControl = true; + } + Flush(); + return output; + } + + private void Apply(string sequence) + { + var parts = sequence.Split(';'); + var codes = new int[parts.Length]; + for (var i = 0; i < parts.Length; i++) + if (!int.TryParse(parts[i].Length == 0 ? "0" : parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out codes[i])) + { UnsupportedControl = true; return; } + for (var i = 0; i < codes.Length; i++) + { + var code = codes[i]; + switch (code) + { + case 0: foreground = background = null; bold = underline = inverse = false; break; + case 1: bold = true; break; + case 4: underline = true; break; + case 7: inverse = true; break; + case 22: bold = false; break; + case 24: underline = false; break; + case 27: inverse = false; break; + case 39: foreground = null; break; + case 49: background = null; break; + case >= 30 and <= 37: foreground = Palette[code - 30]; break; + case >= 40 and <= 47: background = Palette[code - 40]; break; + case >= 90 and <= 97: foreground = Palette[code - 90 + 8]; break; + case >= 100 and <= 107: background = Palette[code - 100 + 8]; break; + case 38 or 48: + string? color = null; + if (i + 2 < codes.Length && codes[i + 1] == 5 && codes[i + 2] is >= 0 and <= 255) + { color = Indexed(codes[i + 2]); i += 2; } + else if (i + 4 < codes.Length && codes[i + 1] == 2 && codes.Skip(i + 2).Take(3).All(value => value is >= 0 and <= 255)) + { color = $"#{codes[i + 2]:X2}{codes[i + 3]:X2}{codes[i + 4]:X2}"; i += 4; } + else { UnsupportedControl = true; return; } + if (code == 38) foreground = color; + else background = color; + break; + default: UnsupportedControl = true; break; + } + } + } + + private static string Indexed(int index) + { + if (index < 16) return Palette[index]; + if (index >= 232) + { + var shade = 8 + (index - 232) * 10; + return $"#{shade:X2}{shade:X2}{shade:X2}"; + } + index -= 16; + int Channel(int value) => value == 0 ? 0 : 55 + value * 40; + return $"#{Channel(index / 36):X2}{Channel(index / 6 % 6):X2}{Channel(index % 6):X2}"; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandForm.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandForm.cs new file mode 100644 index 0000000..4fe8e07 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandForm.cs @@ -0,0 +1,146 @@ +using System.Management.Automation; +using System.Management.Automation.Language; + +namespace Iseberg.Core; + +public enum CommandParameterKind { Text, Switch, Boolean, Choice } + +public sealed record CommandParameterDescription( + string Name, string TypeName, CommandParameterKind Kind, bool IsMandatory, int? Position, + bool IsArray, bool IsCommon, bool AllowsEmptyString, bool AcceptsPipelineInput, + string HelpMessage, IReadOnlyList Aliases, IReadOnlyList Choices, bool ChoicesIgnoreCase = true); + +public sealed record CommandParameterSetDescription( + string Name, bool IsDefault, IReadOnlyList Parameters) +{ + public override string ToString() => Name; +} + +public sealed record CommandFormDescription( + string Name, string InvocationName, IReadOnlyList ParameterSets); + +public sealed class CommandParameterValue +{ + public bool Included { get; set; } + public string Text { get; set; } = ""; + public bool IsExpression { get; set; } + public bool Boolean { get; set; } = true; +} + +public sealed record CommandFormResult(string Script, IReadOnlyList MissingParameters, + IReadOnlyList InvalidExpressions, IReadOnlyList InvalidChoices) +{ + public bool IsValid => MissingParameters.Count == 0 && InvalidExpressions.Count == 0 && InvalidChoices.Count == 0; +} + +public sealed class CommandForm +{ + private readonly Dictionary values = new(StringComparer.OrdinalIgnoreCase); + public CommandFormDescription Description { get; } + public CommandParameterSetDescription SelectedSet { get; private set; } + + public CommandForm(CommandFormDescription description) + { + if (description.ParameterSets.Count == 0) + throw new ArgumentException("A command form must have at least one parameter set.", nameof(description)); + Description = description; + SelectedSet = description.ParameterSets.FirstOrDefault(set => set.IsDefault) ?? description.ParameterSets[0]; + } + + public void SelectSet(string name) => + SelectedSet = Description.ParameterSets.First(set => set.Name == name); + + public CommandParameterValue Value(string name) + { + if (!values.TryGetValue(name, out var value)) values.Add(name, value = new()); + return value; + } + + public CommandFormResult Build() + { + var parts = new List + { + "& " + Quote(Description.InvocationName) + }; + var missing = new List(); + var invalid = new List(); + var invalidChoices = new List(); + foreach (var parameter in SelectedSet.Parameters) + { + var value = Value(parameter.Name); + if (parameter.IsMandatory && (!value.Included || + (parameter.IsArray || parameter.Kind is not (CommandParameterKind.Switch or CommandParameterKind.Boolean)) && + value.Text.Length == 0 && !parameter.AllowsEmptyString)) + missing.Add(parameter.Name); + if (!value.Included) continue; + if (!value.IsExpression && parameter.Choices.Count > 0) + { + var entries = parameter.IsArray ? value.Text.Replace("\r\n", "\n").Split('\n') : [value.Text]; + var comparer = parameter.ChoicesIgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + if (entries.Any(entry => !parameter.Choices.Contains(entry, comparer))) invalidChoices.Add(parameter.Name); + } + if (parameter.Kind == CommandParameterKind.Switch) + { + parts.Add("-" + parameter.Name + (value.Boolean ? "" : ":$false")); + continue; + } + string argument; + if (parameter.Kind == CommandParameterKind.Boolean && !parameter.IsArray) + argument = value.Boolean ? "$true" : "$false"; + else if (value.IsExpression) + { + argument = "(" + value.Text + "\n)"; + Parser.ParseInput(argument, out _, out var errors); + if (string.IsNullOrWhiteSpace(value.Text) || errors.Length > 0) invalid.Add(parameter.Name); + } + else if (parameter.IsArray) + argument = "@(" + string.Join(", ", value.Text.Replace("\r\n", "\n").Split('\n').Select(entry => + parameter.Kind == CommandParameterKind.Boolean && bool.TryParse(entry, out var boolean) + ? boolean ? "$true" : "$false" : Quote(entry))) + ")"; + else + argument = Quote(value.Text); + parts.Add("-" + parameter.Name + " " + argument); + } + return new(string.Join(" ", parts), missing, invalid, invalidChoices); + } + + private static string Quote(string text) => "'" + text.Replace("'", "''") + "'"; + + internal static CommandFormDescription Describe(CommandInfo command) + { + var resolved = command; + while (resolved is AliasInfo alias) + resolved = alias.ResolvedCommand ?? throw new InvalidOperationException($"Cannot resolve alias '{command.Name}'."); + var sets = resolved.ParameterSets.Select(set => new CommandParameterSetDescription(set.Name, set.IsDefault, + set.Parameters.OrderByDescending(p => p.IsMandatory).ThenBy(p => p.Position < 0 ? int.MaxValue : p.Position) + .ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase).Select(DescribeParameter).ToArray())).ToArray(); + return new(command.Name, string.IsNullOrEmpty(command.ModuleName) ? command.Name : command.ModuleName + "\\" + command.Name, + sets.Length == 0 ? [new("__AllParameterSets", true, [])] : sets); + } + + private static CommandParameterDescription DescribeParameter(CommandParameterInfo parameter) + { + var type = parameter.ParameterType; + var element = type.IsArray ? type.GetElementType()! : type; + element = Nullable.GetUnderlyingType(element) ?? element; + var validation = parameter.Attributes.OfType().FirstOrDefault(); + var choices = validation?.ValidValues.ToArray() + ?? (element.IsEnum ? Enum.GetNames(element) : type.IsArray && element == typeof(bool) ? ["True", "False"] : []); + var kind = !type.IsArray && element == typeof(SwitchParameter) ? CommandParameterKind.Switch + : element == typeof(bool) ? CommandParameterKind.Boolean + : choices.Length > 0 ? CommandParameterKind.Choice : CommandParameterKind.Text; + return new(parameter.Name, type.Name, kind, parameter.IsMandatory, + parameter.Position < 0 ? null : parameter.Position, type.IsArray, CommonParameters.Contains(parameter.Name), + parameter.Attributes.OfType().Any(), + parameter.ValueFromPipeline || parameter.ValueFromPipelineByPropertyName, + parameter.Attributes.OfType().FirstOrDefault(a => !string.IsNullOrEmpty(a.HelpMessage))?.HelpMessage ?? "", + parameter.Aliases.ToArray(), choices, validation?.IgnoreCase ?? true); + } + + private static readonly HashSet CommonParameters = new(StringComparer.OrdinalIgnoreCase) + { + "Verbose", "Debug", "ErrorAction", "WarningAction", "InformationAction", "ProgressAction", + "ErrorVariable", "WarningVariable", "InformationVariable", "OutVariable", "OutBuffer", "PipelineVariable", + "WhatIf", "Confirm" + }; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandHelp.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandHelp.cs new file mode 100644 index 0000000..98f6546 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandHelp.cs @@ -0,0 +1,125 @@ +using System.Collections; +using System.Management.Automation; +using System.Text; + +namespace Iseberg.Core; + +public enum HelpSectionKind { Synopsis, Syntax, Description, Parameters, Inputs, Outputs, Notes, Examples, RelatedLinks, Remarks } + +public sealed class HelpViewSettings +{ + public List Sections { get; set; } = [.. Enum.GetValues()]; + public bool MatchCase { get; set; } + public bool WholeWord { get; set; } + public double Zoom { get; set; } = 100; + + public HelpViewSettings Copy() => new() { Sections = [.. Sections], MatchCase = MatchCase, WholeWord = WholeWord, Zoom = Zoom }; + + public void Normalize() + { + Sections ??= [.. Enum.GetValues()]; + if (Sections.Any(section => !Enum.IsDefined(section))) throw new InvalidDataException("An unknown help section was saved."); + Sections = Sections.Distinct().ToList(); + Zoom = double.IsFinite(Zoom) ? Math.Clamp(Zoom, 20, 400) : 100; + } +} + +public sealed record CommandHelpSection(HelpSectionKind Kind, string Text); + +public sealed record CommandHelpDocument(string Name, IReadOnlyList Sections) +{ + public string ToText() => string.Join("\n\n", Sections.Select(section => section.Kind + "\n" + section.Text)); + + public static CommandHelpDocument FromHelp(string name, IEnumerable help) + { + var sections = new Dictionary>(); + void Add(HelpSectionKind kind, string text) + { + if (string.IsNullOrWhiteSpace(text)) return; + if (!sections.TryGetValue(kind, out var list)) sections.Add(kind, list = []); + list.Add(text.Trim()); + } + foreach (var entry in help) + { + if (entry.BaseObject is string topic) + { + Add(HelpSectionKind.Remarks, topic); + continue; + } + Add(HelpSectionKind.Synopsis, Text(Property(entry, "synopsis"))); + Add(HelpSectionKind.Description, Text(Property(entry, "description"))); + Add(HelpSectionKind.Remarks, Text(Property(entry, "remarks"))); + foreach (var syntax in Items(Property(Property(entry, "syntax"), "syntaxItem"))) + { + var builder = new StringBuilder(Text(Property(syntax, "name"))); + foreach (var parameter in Items(Property(syntax, "parameter"))) + { + var required = Text(Property(parameter, "required")).Equals("true", StringComparison.OrdinalIgnoreCase); + var value = Text(Property(parameter, "parameterValue")); + var argument = "-" + Text(Property(parameter, "name")) + (value.Length == 0 ? "" : " <" + value + ">"); + builder.Append(' ').Append(required ? argument : "[" + argument + "]"); + } + Add(HelpSectionKind.Syntax, builder.ToString()); + } + foreach (var parameter in Items(Property(Property(entry, "parameters"), "parameter"))) + { + var type = Text(Property(Property(parameter, "type"), "name")); + var builder = new StringBuilder("-" + Text(Property(parameter, "name")) + (type.Length == 0 ? "" : " <" + type + ">")); + var description = Text(Property(parameter, "description")); + if (description.Length > 0) builder.Append("\n\n ").Append(description.Replace("\n", "\n ")); + foreach (var (property, label) in new[] + { + ("required", "Required?"), ("position", "Position?"), ("defaultValue", "Default value"), + ("pipelineInput", "Accept pipeline input?"), ("globbing", "Accept wildcard characters?") + }) + { + var value = Text(Property(parameter, property)); + if (value.Length > 0) builder.Append("\n ").Append(label.PadRight(28)).Append(value); + } + Add(HelpSectionKind.Parameters, builder.ToString()); + } + foreach (var (kind, container, child) in new[] + { + (HelpSectionKind.Inputs, "inputTypes", "inputType"), (HelpSectionKind.Outputs, "returnValues", "returnValue") + }) + foreach (var item in Items(Property(Property(entry, container), child))) + Add(kind, Join(Text(Property(Property(item, "type"), "name")), Text(Property(item, "description")))); + Add(HelpSectionKind.Notes, Text(Property(Property(entry, "alertSet"), "alert"))); + foreach (var example in Items(Property(Property(entry, "examples"), "example"))) + Add(HelpSectionKind.Examples, Join(Text(Property(example, "title")), Text(Property(example, "introduction")), + Text(Property(example, "code")), Text(Property(example, "remarks")))); + foreach (var link in Items(Property(Property(entry, "relatedLinks"), "navigationLink"))) + Add(HelpSectionKind.RelatedLinks, Join(Text(Property(link, "linkText")), Text(Property(link, "uri")))); + } + if (sections.Count == 0) throw new InvalidDataException($"Command '{name}' did not provide readable help content."); + return new(name, Enum.GetValues().Where(sections.ContainsKey) + .Select(kind => new CommandHelpSection(kind, string.Join("\n\n", sections[kind]))).ToArray()); + } + + private static string Join(params string[] values) => string.Join("\n", values.Where(value => value.Length > 0)); + + private static object? Property(object? value, string name) => + value is null ? null : PSObject.AsPSObject(value).Properties[name]?.Value; + + private static IEnumerable Items(object? value) + { + if (value is PSObject { BaseObject: IEnumerable and not string } wrapper) value = wrapper.BaseObject; + if (value is null) yield break; + if (value is IEnumerable sequence and not string) + { + foreach (var item in sequence) if (item is not null) yield return item; + } + else yield return value; + } + + private static string Text(object? value) + { + if (value is null) return ""; + if (value is PSObject wrapper && wrapper.BaseObject is string text) return text; + if (value is string literal) return literal; + if (Property(value, "Text") is { } content) return Text(content); + if (value is IEnumerable sequence) + return string.Join("\n", sequence.Cast().Select(Text).Where(item => item.Length > 0)); + return value.ToString() ?? ""; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/DebuggerModels.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/DebuggerModels.cs new file mode 100644 index 0000000..4084231 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/DebuggerModels.cs @@ -0,0 +1,49 @@ +using System.Management.Automation; + +namespace Iseberg.Core; + +public enum BreakpointKind { Line, Command, Variable } + +public sealed record BreakpointSpec(BreakpointKind Kind, string? ScriptPath = null, string Target = "", + int Line = 0, string Condition = "", bool Enabled = true, VariableAccessMode AccessMode = VariableAccessMode.Write, + string Action = "") +{ + public void Validate() + { + if (Kind == BreakpointKind.Line && (string.IsNullOrWhiteSpace(ScriptPath) || Line < 1)) + throw new ArgumentException("A line breakpoint requires a saved script and a positive line number."); + if (Kind != BreakpointKind.Line && string.IsNullOrWhiteSpace(Target)) + throw new ArgumentException("Enter a command or variable name."); + if (!Enum.IsDefined(Kind) || !Enum.IsDefined(AccessMode)) + throw new ArgumentException("Invalid breakpoint kind or variable access mode."); + if (!string.IsNullOrWhiteSpace(Condition) && !string.IsNullOrWhiteSpace(Action)) + throw new ArgumentException("Use a condition or an action, not both."); + if (!string.IsNullOrWhiteSpace(Condition)) _ = ScriptBlock.Create($"if ({Condition}) {{ break }}"); + if (!string.IsNullOrWhiteSpace(Action)) _ = ScriptBlock.Create(Action); + } +} + +public sealed record DebugBreakpoint(int Id, BreakpointSpec Spec, int HitCount) +{ + public override string ToString() => + $"#{Id} {(Spec.Enabled ? "[on]" : "[off]")} {Spec.Kind}: " + + (Spec.Kind == BreakpointKind.Line ? $"{Spec.ScriptPath}:{Spec.Line}" : Spec.Target) + + (Spec.Kind == BreakpointKind.Variable ? $" ({Spec.AccessMode})" : "") + + (string.IsNullOrWhiteSpace(Spec.Condition) ? "" : $" if {Spec.Condition}") + + (string.IsNullOrWhiteSpace(Spec.Action) ? "" : $" {{{Spec.Action}}}"); +} + +public sealed record DebugValue(string Name, string Value, string Type, string? Error = null, long? Reference = null) +{ + public override string ToString() => Error is null ? $"{Name} = {Value} [{Type}]" : $"{Name}: {Error}"; +} + +public sealed record DebugChildren(IReadOnlyList Values, int? NextOffset); + +public sealed record DebugFrame(string FunctionName, string? ScriptPath, int Line, int Index = 0) +{ + public override string ToString() => $"{FunctionName} - {ScriptPath ?? ""}:{Line}"; +} + +public sealed record DebugSnapshot(IReadOnlyList Variables, IReadOnlyList Watches, + IReadOnlyList CallStack); diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs new file mode 100644 index 0000000..0d1796d --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs @@ -0,0 +1,333 @@ +using System.Management.Automation.Language; +using System.Management.Automation; +using System.Xml; + +namespace Iseberg.Core; + +public sealed record ScriptDiagnostic(string Code, string Message, int Start, int End); +public sealed record XmlTokenSpan(int Start, int End, string Kind); + +public sealed record ScriptAnalysis(Token[] Tokens, ParseError[] Errors, IReadOnlyList<(int Start, int End)> Folds) +{ + public bool IsXml { get; init; } + public IReadOnlyList XmlTokens { get; init; } = []; + public IReadOnlyList Diagnostics { get; init; } = Errors.Select(error => + new ScriptDiagnostic(error.ErrorId, error.Message, error.Extent.StartOffset, error.Extent.EndOffset)).ToArray(); +} + +public static class EditorAnalysis +{ + public static string? CommandNameAtCaret(string text, int caret) + { + ArgumentOutOfRangeException.ThrowIfNegative(caret); + ArgumentOutOfRangeException.ThrowIfGreaterThan(caret, text.Length); + var ast = Parser.ParseInput(text, out _, out _); + return ast.FindAll(node => node is CommandAst && node.Extent.StartOffset <= caret && + node.Extent.EndOffset >= caret, searchNestedScriptBlocks: true) + .OfType().OrderBy(command => command.Extent.EndOffset - command.Extent.StartOffset) + .FirstOrDefault()?.GetCommandName(); + } + + public static (int Open, int Close)? MatchingBrace(string text, int caret) + { + ArgumentOutOfRangeException.ThrowIfNegative(caret); + ArgumentOutOfRangeException.ThrowIfGreaterThan(caret, text.Length); + var stack = new Stack<(TokenKind Kind, int Offset)>(); + var pairs = new List<(int Open, int Close)>(); + Parser.ParseInput(text, out var tokens, out _); + foreach (var token in Flatten(tokens)) + { + var kind = token.Kind switch + { + TokenKind.AtCurly => TokenKind.LCurly, + TokenKind.AtParen or TokenKind.DollarParen => TokenKind.LParen, + _ => token.Kind + }; + if (kind is TokenKind.LCurly or TokenKind.LParen or TokenKind.LBracket) + stack.Push((kind, token.Extent.EndOffset - 1)); + else if (kind is TokenKind.RCurly or TokenKind.RParen or TokenKind.RBracket) + { + var expected = kind switch + { + TokenKind.RCurly => TokenKind.LCurly, + TokenKind.RParen => TokenKind.LParen, + _ => TokenKind.LBracket + }; + if (stack.TryPeek(out var open) && open.Kind == expected) + { + stack.Pop(); + pairs.Add((open.Offset, token.Extent.StartOffset)); + } + else stack.Clear(); + } + } + foreach (var offset in new[] { caret, caret - 1 }) + foreach (var pair in pairs) + if (pair.Open == offset || pair.Close == offset) return pair; + return null; + } + + private static IEnumerable Flatten(IEnumerable tokens) + { + foreach (var token in tokens) + { + if (token is StringExpandableToken { NestedTokens: { } nested }) + { + foreach (var child in Flatten(nested)) yield return child; + } + else yield return token; + } + } + + public static IReadOnlySet? CompletionFilter(string text, int caret) + { + if (caret < 1 || caret > text.Length) return null; + Parser.ParseInput(text, out var tokens, out _); + var current = TokenAtCaret(tokens, caret); + var inString = current?.Kind is TokenKind.StringLiteral or TokenKind.StringExpandable or TokenKind.HereStringLiteral or TokenKind.HereStringExpandable; + if (current?.Kind == TokenKind.Comment) return null; + var character = text[caret - 1]; + if (character is '\\' or '/' && (inString || current?.Kind is TokenKind.Generic or TokenKind.Identifier)) + return new HashSet { CompletionResultType.ProviderItem, CompletionResultType.ProviderContainer }; + if (character == '$' && current?.Kind is TokenKind.StringExpandable or TokenKind.HereStringExpandable) + return new HashSet { CompletionResultType.Variable }; + if (inString) return null; + return character switch + { + '$' => new HashSet { CompletionResultType.Variable }, + '-' when current?.Kind is TokenKind.Parameter or TokenKind.Generic || + current?.TokenFlags.HasFlag(TokenFlags.CommandName) == true => + new HashSet { CompletionResultType.Command, CompletionResultType.ParameterName }, + ':' when caret >= 2 && text[caret - 2] == ':' => + new HashSet { CompletionResultType.Method, CompletionResultType.Property }, + '.' when caret >= 2 && !char.IsWhiteSpace(text[caret - 2]) && current?.Kind != TokenKind.Number && + tokens.LastOrDefault(t => t.Extent.EndOffset <= caret - 1)?.Kind != TokenKind.Number && + text[caret - 2] is not ('*' or '?') => + new HashSet { CompletionResultType.Method, CompletionResultType.Property, CompletionResultType.Type, CompletionResultType.Namespace }, + '[' => new HashSet { CompletionResultType.Type, CompletionResultType.Namespace }, + ' ' when tokens.LastOrDefault(t => t.Extent.EndOffset <= caret - 1 && t.Kind != TokenKind.EndOfInput)?.Kind == TokenKind.Parameter => + new HashSet { CompletionResultType.ParameterValue }, + _ => null + }; + } + + private static Token? TokenAtCaret(IEnumerable tokens, int caret) + { + var token = tokens.FirstOrDefault(t => t.Extent.StartOffset < caret && t.Extent.EndOffset >= caret); + if (token is StringExpandableToken { NestedTokens: { } nested }) + return TokenAtCaret(nested, caret) ?? token; + return token; + } + + public static bool IsXmlDocument(string? path) => + Path.GetExtension(path)?.Equals(".xml", StringComparison.OrdinalIgnoreCase) == true || + Path.GetExtension(path)?.Equals(".ps1xml", StringComparison.OrdinalIgnoreCase) == true; + + public static VariableToken? VariableAtOffset(IEnumerable tokens, int offset) + { + foreach (var token in tokens) + { + if (offset < token.Extent.StartOffset || offset >= token.Extent.EndOffset) continue; + if (token is VariableToken variable) return variable; + if (token is StringExpandableToken { NestedTokens: { } nested }) + return VariableAtOffset(nested, offset); + } + return null; + } + + public static (int Start, int End)? StatementAtPosition(string text, int line, int column) + { + if (line < 1 || column < 1) return null; + var offset = OffsetAtPosition(text, line, column, out var valid); + if (!valid || offset >= text.Length) return null; + var ast = Parser.ParseInput(text, out _, out _); + // A pipeline is the executable statement, rather than its individual command or variable. + var statement = ast.FindAll(node => node is StatementAst && node is not StatementBlockAst && + node.Extent.StartOffset == offset, searchNestedScriptBlocks: true) + .OrderBy(node => node.Extent.EndOffset - node.Extent.StartOffset).FirstOrDefault(); + return statement is null ? null : (statement.Extent.StartOffset, statement.Extent.EndOffset); + } + + public static ScriptAnalysis Analyze(string text) => Analyze(text, null); + + public static ScriptAnalysis Analyze(string text, string? documentPath) + { + ArgumentNullException.ThrowIfNull(text); + if (IsXmlDocument(documentPath)) return AnalyzeXml(text); + Parser.ParseInput(text, out var tokens, out var errors); + var stack = new Stack(); + var regions = new Stack(); + var regionFolds = new List<(int Start, int End)>(); + var folds = new List<(int Start, int End)>(); + foreach (var token in Flatten(tokens)) + { + if (token.Kind is TokenKind.LCurly or TokenKind.AtCurly) + stack.Push(token); + else if (token.Kind == TokenKind.RCurly && stack.TryPop(out var open) && + token.Extent.EndLineNumber > open.Extent.StartLineNumber) + folds.Add((open.Extent.StartOffset, token.Extent.EndOffset)); + } + foreach (var token in tokens) + { + if (token.Kind == TokenKind.Comment && !token.Text.StartsWith("<#", StringComparison.Ordinal) && + IsLineDirective(text, token)) + { + if (IsDirective(token.Text, "#region")) regions.Push(token); + else if (IsDirective(token.Text, "#endregion") && regions.TryPop(out var region) && + token.Extent.StartLineNumber > region.Extent.StartLineNumber) + regionFolds.Add((region.Extent.StartOffset, token.Extent.EndOffset)); + } + if (token.Extent.EndLineNumber <= token.Extent.StartLineNumber) continue; + var closed = token.Kind switch + { + TokenKind.Comment => token.Text.StartsWith("<#", StringComparison.Ordinal) && + token.Text.EndsWith("#>", StringComparison.Ordinal), + TokenKind.HereStringLiteral => token.Text.EndsWith("'@", StringComparison.Ordinal), + TokenKind.HereStringExpandable => token.Text.EndsWith("\"@", StringComparison.Ordinal), + TokenKind.StringLiteral => token.Text.EndsWith("'", StringComparison.Ordinal), + TokenKind.StringExpandable => token.Text.EndsWith("\"", StringComparison.Ordinal), + _ => false + }; + if (closed && !errors.Any(error => error.IncompleteInput && + error.Extent.StartOffset >= token.Extent.StartOffset && error.Extent.StartOffset < token.Extent.EndOffset)) + folds.Add((token.Extent.StartOffset, token.Extent.EndOffset)); + } + // Unclosed outer regions must not hide even otherwise matched nested directives. + var unmatchedRegion = regions.Count == 0 ? int.MaxValue : regions.Min(region => region.Extent.StartOffset); + folds.AddRange(regionFolds.Where(fold => fold.Start < unmatchedRegion)); + return new(tokens, errors, folds.Distinct().OrderBy(f => f.Start).ThenByDescending(f => f.End).ToArray()); + } + + private static bool IsDirective(string text, string directive) => + text.StartsWith(directive, StringComparison.OrdinalIgnoreCase) && + (text.Length == directive.Length || char.IsWhiteSpace(text[directive.Length])); + + private static bool IsLineDirective(string text, Token token) + { + for (var index = token.Extent.StartOffset - 1; index >= 0 && text[index] is not ('\r' or '\n'); index--) + if (!char.IsWhiteSpace(text[index])) return false; + return true; + } + + private static int OffsetAtPosition(string text, int line, int column, out bool valid) + { + var currentLine = 1; + var index = 0; + while (index < text.Length && currentLine < line) + { + if (text[index++] == '\r') + { + if (index < text.Length && text[index] == '\n') index++; + currentLine++; + } + else if (text[index - 1] == '\n') currentLine++; + } + var start = index; + while (index < text.Length && text[index] is not ('\r' or '\n')) index++; + valid = currentLine == line && column > 0 && column - 1 <= index - start; + return valid ? start + column - 1 : text.Length; + } + + private static ScriptAnalysis AnalyzeXml(string text) + { + var diagnostics = new List(); + try + { + using var input = new StringReader(text); + using var reader = XmlReader.Create(input, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }); + while (reader.Read()) { } + } + catch (XmlException exception) + { + var start = OffsetAtPosition(text, Math.Max(1, exception.LineNumber), Math.Max(1, exception.LinePosition), out _); + diagnostics.Add(new("InvalidXml", exception.Message, start, Math.Min(text.Length, start + 1))); + } + + var spans = new List(); + var folds = new List<(int Start, int End)>(); + var elements = new Stack<(string Name, int Start)>(); + void Fold(int start, int end) + { + if (text.AsSpan(start, end - start).IndexOfAny('\r', '\n') >= 0) folds.Add((start, end)); + } + var offset = 0; + while (offset < text.Length) + { + var start = text.IndexOf('<', offset); + if (start < 0) break; + var remaining = text.AsSpan(start); + var terminator = remaining.StartsWith("" : + remaining.StartsWith("" : + remaining.StartsWith("" : null; + if (terminator is not null) + { + var end = text.IndexOf(terminator, start + 2, StringComparison.Ordinal); + var closed = end >= 0; + end = closed ? end + terminator.Length : text.Length; + spans.Add(new(start, end, terminator == "-->" ? "Comment" : terminator == "]]>" ? "Value" : "Tag")); + if (closed) Fold(start, end); + offset = end; + continue; + } + if (remaining.StartsWith("' or '<')) cursor++; + if (cursor == nameStart) { offset = start + 1; continue; } + var name = text[nameStart..cursor]; + spans.Add(new(nameStart, cursor, "Tag")); + var complete = false; + var empty = false; + while (cursor < text.Length) + { + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) cursor++; + if (cursor >= text.Length || text[cursor] == '<') break; + if (text[cursor] == '>' || text[cursor] == '/' && cursor + 1 < text.Length && text[cursor + 1] == '>') + { + empty = text[cursor] == '/'; + cursor += empty ? 2 : 1; + complete = true; + break; + } + var attributeStart = cursor; + while (cursor < text.Length && !char.IsWhiteSpace(text[cursor]) && text[cursor] is not ('=' or '<' or '>' or '/')) cursor++; + if (cursor == attributeStart) break; + spans.Add(new(attributeStart, cursor, "Attribute")); + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) cursor++; + if (cursor >= text.Length || text[cursor++] != '=') break; + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) cursor++; + if (cursor >= text.Length || text[cursor] is not ('\'' or '"')) break; + var valueStart = cursor; + var quote = text[cursor++]; + while (cursor < text.Length && text[cursor] != quote) cursor++; + if (cursor >= text.Length) break; + spans.Add(new(valueStart, ++cursor, "Value")); + } + if (complete) + { + if (closing) + { + if (elements.TryPeek(out var element) && element.Name == name) + { + elements.Pop(); + Fold(element.Start, cursor); + } + else elements.Clear(); + } + else if (!empty) elements.Push((name, start)); + } + offset = Math.Max(start + 1, cursor); + } + return new([], [], folds.OrderBy(fold => fold.Start).ToArray()) + { + IsXml = true, + XmlTokens = spans, + Diagnostics = diagnostics + }; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorTheme.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorTheme.cs new file mode 100644 index 0000000..a8f44ba --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorTheme.cs @@ -0,0 +1,46 @@ +namespace Iseberg.Core; + +public sealed class EditorTheme +{ + public string Name { get; set; } = "Dark Console, Light Editor (default)"; + public Dictionary Colors { get; set; } = DefaultColors(); + + public EditorTheme Copy() => new() { Name = Name, Colors = new(Colors) }; + + public static Dictionary DefaultColors() => new() + { + ["Script.Foreground"] = "#000000", ["Script.Background"] = "#FFFFFF", + ["Console.Foreground"] = "#FFFFFF", ["Console.Background"] = "#012456", + ["Console.TextBackground"] = "#012456", + ["Script.Comment"] = "#006400", ["Script.Keyword"] = "#0000FF", + ["Script.String"] = "#8B0000", ["Script.Variable"] = "#FF4500", + ["Script.Number"] = "#FF00FF", ["Script.Command"] = "#0000FF", + ["Script.Function"] = "#FF00FF", ["Script.Attribute"] = "#00BFFF", + ["Script.CommandArgument"] = "#FF00FF", ["Script.Label"] = "#0000FF", + ["Script.Parameter"] = "#000080", ["Script.Type"] = "#008080", + ["Script.Operator"] = "#000000", ["Script.Member"] = "#000000", + ["Console.Comment"] = "#90EE90", ["Console.Keyword"] = "#00FFFF", + ["Console.String"] = "#FFFF00", ["Console.Variable"] = "#FF4500", + ["Console.Number"] = "#EE82EE", ["Console.Command"] = "#FFFFFF", + ["Console.Function"] = "#EE82EE", ["Console.Attribute"] = "#00FFFF", + ["Console.CommandArgument"] = "#EE82EE", ["Console.Label"] = "#00FFFF", + ["Console.Parameter"] = "#FFFFFF", ["Console.Type"] = "#00FFFF", + ["Console.Operator"] = "#FFFFFF", ["Console.Member"] = "#FFFFFF", + ["Xml.Comment"] = "#006400", ["Xml.Tag"] = "#800000", + ["Xml.Attribute"] = "#FF0000", ["Xml.Value"] = "#0000FF", + ["Stream.Error"] = "#FF0000", ["Stream.Warning"] = "#FFFF00", + ["Stream.Verbose"] = "#FFFF00", ["Stream.Debug"] = "#FFFF00" + }; + + public static bool IsColor(string? value) => value is { Length: 7 } && value[0] == '#' && + value.AsSpan(1).ContainsAnyExcept("0123456789abcdefABCDEF".AsSpan()) == false; + + public void Normalize() + { + var defaults = DefaultColors(); + Colors ??= []; + foreach (var (key, value) in defaults) + if (!Colors.TryGetValue(key, out var color) || !IsColor(color)) Colors[key] = value; + if (string.IsNullOrWhiteSpace(Name)) Name = "Custom"; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorThemePresets.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorThemePresets.cs new file mode 100644 index 0000000..deaffd2 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorThemePresets.cs @@ -0,0 +1,151 @@ +namespace Iseberg.Core; + +public static class EditorThemePresets +{ + public const string DarkConsoleLightEditor = "Dark Console, Light Editor (default)"; + public const string LightConsoleDarkEditor = "Light Console, Dark Editor"; + public const string DarkConsoleDarkEditor = "Dark Console, Dark Editor"; + public const string LightConsoleLightEditor = "Light Console, Light Editor"; + public const string MonochromeGreen = "Monochrome Green"; + public const string Presentation = "Presentation"; + public static IReadOnlyList OriginalNames { get; } = + [DarkConsoleLightEditor, LightConsoleDarkEditor, DarkConsoleDarkEditor, LightConsoleLightEditor, MonochromeGreen, Presentation]; + + public static IReadOnlyList OriginalBuiltIns() => OriginalNames.Select(Original).ToArray(); + + public static double FontSizePoints(string name) => name switch + { + MonochromeGreen => 11, + Presentation => 20, + _ when OriginalNames.Contains(name) => 9, + _ => throw new ArgumentException("Unknown original ISE theme.", nameof(name)) + }; + + public static EditorTheme Original(string name) + { + if (!OriginalNames.Contains(name)) throw new ArgumentException("Unknown original ISE theme.", nameof(name)); + var darkScript = name is LightConsoleDarkEditor or DarkConsoleDarkEditor or MonochromeGreen; + var darkConsole = name is DarkConsoleLightEditor or DarkConsoleDarkEditor or MonochromeGreen or Presentation; + var theme = new EditorTheme { Name = name }; + Set(theme, "Script.Background", darkScript ? "#012456" : "#FFFFFF"); + Set(theme, "Script.Foreground", darkScript ? "#F5F5F5" : "#000000"); + Set(theme, "Console.Background", darkConsole ? "#012456" : "#FFFFFF"); + Set(theme, "Console.TextBackground", "#012456"); + Set(theme, "Console.Foreground", darkConsole ? "#F5F5F5" : "#626262"); + SetTokenPalette(theme, "Script", darkScript, defaultLight: name == DarkConsoleLightEditor); + SetTokenPalette(theme, "Console", darkConsole, defaultLight: false); + Set(theme, "Xml.Tag", "#8B0000"); + Set(theme, "Xml.Attribute", "#FF0000"); + Set(theme, "Xml.Value", "#00008B"); + Set(theme, "Stream.Error", darkConsole ? "#FF9494" : "#E50000"); + Set(theme, "Stream.Warning", darkConsole ? "#FF8C00" : "#B26200"); + Set(theme, "Stream.Verbose", darkConsole ? "#00FFFF" : "#007F7F"); + Set(theme, "Stream.Debug", darkConsole ? "#00FFFF" : "#007F7F"); + if (name is MonochromeGreen or Presentation) + { + Set(theme, "Console.Background", "#000000"); + Set(theme, "Console.TextBackground", "#000000"); + Set(theme, "Stream.Error", "#FF0000"); + Set(theme, "Stream.Warning", "#FF8C00"); + Set(theme, "Stream.Verbose", "#0000FF"); + Set(theme, "Stream.Debug", "#0000FF"); + } + if (name == MonochromeGreen) + { + Set(theme, "Script.Background", "#000000"); + Set(theme, "Script.Foreground", "#00FF00"); + Set(theme, "Console.Foreground", "#00FF00"); + var script = new Dictionary + { + ["Attribute"]="#009F00", ["Command"]="#00BF00", ["CommandArgument"]="#00DF00", + ["Parameter"]="#00FF00", ["Comment"]="#007F00", ["Keyword"]="#00DF00", + ["Label"]="#00FF00", ["Member"]="#00DF00", ["Number"]="#009F00", + ["Operator"]="#007F00", ["String"]="#00DF00", ["Type"]="#00FF00", ["Variable"]="#00BF00" + }; + var console = new Dictionary + { + ["Attribute"]="#00FF00", ["Command"]="#00DF00", ["CommandArgument"]="#00BF00", + ["Parameter"]="#009F00", ["Comment"]="#007F00", ["Keyword"]="#00DF00", + ["Label"]="#00DF00", ["Member"]="#00BF00", ["Number"]="#009F00", + ["Operator"]="#009F00", ["String"]="#00FF00", ["Type"]="#009F00", ["Variable"]="#00BF00" + }; + foreach (var (token, color) in script) Set(theme, "Script." + token, color); + foreach (var (token, color) in console) Set(theme, "Console." + token, color); + Set(theme, "Script.Function", script["Command"]); + Set(theme, "Console.Function", console["Command"]); + } + return theme; + } + + private static void SetTokenPalette(EditorTheme theme, string prefix, bool dark, bool defaultLight) + { + var palette = dark ? new Dictionary + { + ["Attribute"]="#B0C4DE", ["Command"]="#E0FFFF", ["CommandArgument"]="#EE82EE", + ["Parameter"]="#FFE4B5", ["Comment"]="#98FB98", ["Keyword"]="#E0FFFF", + ["Label"]="#E0FFFF", ["Member"]="#F5F5F5", ["Number"]="#FFE4C4", + ["Operator"]="#D3D3D3", ["String"]="#DB7093", ["Type"]="#8FBC8F", ["Variable"]="#FF4500" + } : new Dictionary + { + ["Attribute"]="#00BFFF", ["Command"]="#0000FF", ["CommandArgument"]="#8A2BE2", + ["Parameter"]="#000080", ["Comment"]="#006400", ["Keyword"]="#00008B", + ["Label"]="#00008B", ["Member"]="#000000", ["Number"]="#800080", + ["Operator"]=defaultLight ? "#696969" : "#A9A9A9", ["String"]="#8B0000", + ["Type"]=defaultLight ? "#006161" : "#008080", ["Variable"]=defaultLight ? "#A82D00" : "#FF4500" + }; + foreach (var (token, color) in palette) Set(theme, prefix + "." + token, color); + Set(theme, prefix + ".Function", palette["Command"]); + } + + public static EditorTheme Classic() => new() { Name = "Classic ISE" }; + + public static EditorTheme Light() + { + var theme = Classic(); + theme.Name = "Light"; + Set(theme, "Console.Foreground", "#202020"); + Set(theme, "Console.Background", "#FFFFFF"); + Set(theme, "Console.TextBackground", "#FFFFFF"); + Set(theme, "Script.Variable", "#9A3412"); + Set(theme, "Script.Number", "#9333EA"); + Set(theme, "Script.Function", "#9333EA"); + Set(theme, "Script.CommandArgument", "#9333EA"); + Set(theme, "Stream.Warning", "#8A5A00"); + Set(theme, "Stream.Verbose", "#715C00"); + Set(theme, "Stream.Debug", "#715C00"); + return theme; + } + + public static EditorTheme Dark() + { + var theme = Classic(); + theme.Name = "Dark"; + Set(theme, "Script.Foreground", "#D4D4D4"); + Set(theme, "Script.Background", "#1E1E1E"); + Set(theme, "Console.Foreground", "#D4D4D4"); + Set(theme, "Console.Background", "#1E1E1E"); + Set(theme, "Console.TextBackground", "#1E1E1E"); + foreach (var prefix in new[] { "Script", "Console" }) + { + foreach (var (token, color) in new Dictionary + { + ["Comment"] = "#8AB66B", ["Keyword"] = "#569CD6", ["String"] = "#CE9178", + ["Variable"] = "#9CDCFE", ["Number"] = "#B5CEA8", ["Command"] = "#DCDCAA", + ["Function"] = "#DCDCAA", ["Attribute"] = "#4EC9B0", ["CommandArgument"] = "#D4D4D4", + ["Label"] = "#C586C0", ["Parameter"] = "#9CDCFE", ["Type"] = "#4EC9B0", + ["Operator"] = "#D4D4D4", ["Member"] = "#D4D4D4", + }) Set(theme, prefix + "." + token, color); + } + Set(theme, "Xml.Comment", "#8AB66B"); + Set(theme, "Xml.Tag", "#569CD6"); + Set(theme, "Xml.Attribute", "#9CDCFE"); + Set(theme, "Xml.Value", "#CE9178"); + Set(theme, "Stream.Error", "#F48771"); + Set(theme, "Stream.Warning", "#DCDCAA"); + Set(theme, "Stream.Verbose", "#DCDCAA"); + Set(theme, "Stream.Debug", "#DCDCAA"); + return theme; + } + + private static void Set(EditorTheme theme, string key, string color) => theme.Colors[key] = color; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetCommands.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetCommands.cs new file mode 100644 index 0000000..69b5998 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetCommands.cs @@ -0,0 +1,89 @@ +using System.Management.Automation; + +namespace Iseberg.Core; + +public abstract class IseSnippetCommand : PSCmdlet +{ + protected IseSnippetService Snippets => + Host.PrivateData?.BaseObject is WorkbenchHostServices services && !services.IsRunspacePushed() + ? services.Snippets : throw new PSNotSupportedException("ISE snippet commands require a local Iseberg PowerShell tab."); + + protected string FileSystemPath(string path) + { + var result = SessionState.Path.GetUnresolvedProviderPathFromPSPath(path, out var provider, out _); + if (provider.Name != "FileSystem") throw new PSNotSupportedException("ISE snippets require a FileSystem path."); + return result; + } +} + +[Cmdlet(VerbsCommon.New, "IseSnippet", SupportsShouldProcess = true)] +public sealed class NewIseSnippetCommand : IseSnippetCommand +{ + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNullOrEmpty] + public string Title { get; set; } = ""; + [Parameter(Mandatory = true, Position = 1)] + [ValidateNotNullOrEmpty] + public string Description { get; set; } = ""; + [Parameter(Mandatory = true, Position = 2)] + [ValidateNotNullOrEmpty] + public string Text { get; set; } = ""; + [Parameter] + public string Author { get; set; } = ""; + [Parameter] + [ValidateRange(0, int.MaxValue)] + public int CaretOffset { get; set; } + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void EndProcessing() + { + var snippets = Snippets; + if (ShouldProcess(Title, "Create ISE snippet")) + snippets.Create(Title, Description, Text, Author, CaretOffset, Force); + } +} + +[Cmdlet(VerbsCommon.Get, "IseSnippet")] +[OutputType(typeof(FileInfo))] +public sealed class GetIseSnippetCommand : IseSnippetCommand +{ + protected override void EndProcessing() + { + foreach (var file in Snippets.GetUserFiles()) WriteObject(file); + } +} + +[Cmdlet(VerbsData.Import, "IseSnippet", DefaultParameterSetName = "FromFolder")] +public sealed class ImportIseSnippetCommand : IseSnippetCommand +{ + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "FromFolder")] + [ValidateNotNullOrEmpty] + public string Path { get; set; } = ""; + [Parameter(Mandatory = true, ParameterSetName = "FromModule")] + [ValidateNotNullOrEmpty] + public string Module { get; set; } = ""; + [Parameter(ParameterSetName = "FromModule")] + public SwitchParameter ListAvailable { get; set; } + [Parameter] + public SwitchParameter Recurse { get; set; } + + protected override void EndProcessing() + { + var snippets = Snippets; + if (ParameterSetName == "FromFolder") + { + snippets.Import(FileSystemPath(Path), Recurse); + return; + } + var modules = InvokeCommand.InvokeScript(false, ScriptBlock.Create( + "Get-Module -Name $args[0] -ListAvailable:($args[1]) -ErrorAction Stop"), null, Module, ListAvailable.IsPresent) + .Select(value => value.BaseObject).OfType().ToArray(); + if (modules.Length == 0) throw new InvalidOperationException($"Module '{Module}' was not found. Use -ListAvailable for installed modules."); + foreach (var module in modules) + { + var directory = System.IO.Path.Combine(module.ModuleBase, "Snippets"); + if (Directory.Exists(directory)) snippets.Import(directory, Recurse); + } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs new file mode 100644 index 0000000..d0e2825 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs @@ -0,0 +1,46 @@ +namespace Iseberg.Core; + +public sealed class IseSnippetService(string? userDirectory = null) +{ + private readonly object sync = new(); + private readonly List imported = []; + public string UserDirectory { get; } = userDirectory ?? SnippetCatalog.UserDirectory; + private IReadOnlyList Directories => userDirectory is null ? SnippetCatalog.DefaultDirectories : [UserDirectory]; + + public IEnumerable GetUserFiles() => Directories.Where(Directory.Exists) + .SelectMany(directory => Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + .Where(IsSnippetFile).Order(StringComparer.Ordinal).Select(path => new FileInfo(path)); + + public void Create(string title, string description, string text, string author, int caretOffset, bool force) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + if (title is "." or ".." || title.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + title.IndexOfAny(['\\', '/', ':', '*', '?', '"', '<', '>', '|']) >= 0 || title.EndsWith('.') || title.EndsWith(' ')) + throw new ArgumentException("The snippet title must be a valid filename without directory separators.", nameof(title)); + if (caretOffset < 0 || caretOffset > text.Length) throw new ArgumentOutOfRangeException(nameof(caretOffset)); + SnippetCatalog.SaveAsync(Path.Combine(UserDirectory, title + ".snippets.ps1xml"), + [new(title, description, author, text, caretOffset, Indent: false)], force).GetAwaiter().GetResult(); + } + + public void Import(string path, bool recurse) + { + var paths = Directory.Exists(path) + ? Directory.EnumerateFiles(path, "*", recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly) + .Where(IsSnippetFile).Order(StringComparer.Ordinal).ToArray() + : File.Exists(path) && IsSnippetFile(path) ? [path] : + throw new FileNotFoundException("Specify an existing snippet file or directory.", path); + // Validate the whole import before publishing any entries. + var snippets = paths.SelectMany(file => SnippetCatalog.Parse(File.ReadAllText(file))).ToArray(); + lock (sync) + foreach (var snippet in snippets) + if (!imported.Contains(snippet)) imported.Add(snippet); + } + + public async Task LoadAsync() + { + var result = await SnippetCatalog.LoadAsync(Directories); + lock (sync) return result with { Snippets = result.Snippets.Concat(imported).Distinct().ToArray() }; + } + + private static bool IsSnippetFile(string path) => path.EndsWith(".snippets.ps1xml", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/Iseberg.Core.csproj b/src/Devolutions.Terminal.Ise/Iseberg.Core/Iseberg.Core.csproj new file mode 100644 index 0000000..6b15f56 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/Iseberg.Core.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Debugger.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Debugger.cs new file mode 100644 index 0000000..d8aeda3 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Debugger.cs @@ -0,0 +1,366 @@ +using System.Collections; +using System.Management.Automation; + +namespace Iseberg.Core; + +public sealed partial class PowerShellSession +{ + private readonly Queue debugWork = new(); + private readonly Dictionary breakpointSpecs = []; + private readonly Dictionary debugValues = []; + private long nextDebugValue; + private bool resumeRequested; + public bool IsDebuggerPaused + { + get { lock (sync) return State == SessionState.Debugging && !resumeRequested; } + } + + private sealed record DebugWork(Action Execute, Action Fail); + + // ProcessCommand must run on the suspended pipeline's DebuggerStop thread, not a second pipeline. + private Task PausedQueryAsync(Func query, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (State != SessionState.Debugging || resumeRequested) + throw new InvalidOperationException("The debugger is not paused."); + debugWork.Enqueue(new(() => + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + using var registration = cancellationToken.Register(() => runspace.Debugger.StopProcessCommand()); + var value = query(); + cancellationToken.ThrowIfCancellationRequested(); + result.TrySetResult(value); + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { result.TrySetCanceled(cancellationToken); } + catch (Exception exception) { result.TrySetException(exception); } + }, exception => result.TrySetException(exception))); + debuggerWake.Set(); + } + return result.Task.WaitAsync(cancellationToken); + } + + private async Task BreakpointQueryAsync(Func query, bool mutation = true) + { + if (mutation) await PauseForBreakpointEditAsync(); + return State == SessionState.Debugging ? await PausedQueryAsync(query) : await QueryAsync(_ => query(), waitForGate: mutation); + } + + public async Task PauseForBreakpointEditAsync(CancellationToken cancellationToken = default) + { + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void Changed(SessionState state) + { + if (state is SessionState.Debugging or SessionState.Ready) ready.TrySetResult(); + else if (state == SessionState.Disposed) ready.TrySetException(new ObjectDisposedException(nameof(PowerShellSession))); + } + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + if (State == SessionState.Ready || IsDebuggerPaused) return; + if (State != SessionState.Running || stopRequested) + throw new InvalidOperationException("Wait for execution to stop before editing breakpoints."); + StateChanged += Changed; + try { BreakAll(); } + catch { StateChanged -= Changed; throw; } + } + try { await ready.Task.WaitAsync(cancellationToken); } + finally { StateChanged -= Changed; } + } + + public void BreakAll() + { + lock (sync) + { + if (State != SessionState.Running || stopRequested) + throw new InvalidOperationException("A script must be running to break execution."); + runspace.Debugger.SetDebuggerStepMode(true); + } + } + + public Task EvaluateAsync(string script) => PausedQueryAsync(() => + { + Output?.Invoke(new(DebugPrompt + script + Environment.NewLine, OutputKind.Command, DebugPrompt.Length)); + var command = new PSCommand(); + command.AddScript(script, useLocalScope: false).AddCommand("Out-Default"); + using var output = new PSDataCollection(); + var result = runspace.Debugger.ProcessCommand(command, output); + if (result.ResumeAction is { } action) + { + lock (sync) + { + resumeAction = action; + resumeRequested = true; + } + } + return true; + }); + + private PSObject[] Inspect(string script) + { + var command = new PSCommand(); + command.AddScript(script, useLocalScope: false); + return Inspect(command); + } + + private PSObject[] Inspect(PSCommand command) + { + using var output = new PSDataCollection(); + runspace.Debugger.ProcessCommand(command, output); + var errors = output.Where(value => value.BaseObject is ErrorRecord).Select(value => value.ToString()).ToArray(); + if (errors.Length > 0) throw new InvalidOperationException(string.Join(Environment.NewLine, errors)); + return output.ToArray(); + } + + public Task InspectAsync(IEnumerable watches, int frameIndex = 0) + { + var expressions = watches.ToArray(); + return PausedQueryAsync(() => + { + if (IsRemote && frameIndex != 0) + throw new InvalidOperationException("Remote PowerShell exposes evaluation only in the stopped frame. Select the stopped frame to inspect variables."); + var frames = IsRemote ? [] : runspace.Debugger.GetCallStack().ToArray(); + if (frameIndex < 0 || frameIndex > 0 && frameIndex >= frames.Length) + throw new InvalidOperationException("The selected call-stack frame no longer exists."); + var scope = frames.Take(frameIndex).Count(frame => frame.InvocationInfo.InvocationName != "."); + var remoteVariables = IsRemote ? Inspect("Microsoft.PowerShell.Utility\\Get-Variable") : []; + var frameVariables = IsRemote ? [] : frameIndex == 0 + ? Inspect("Microsoft.PowerShell.Utility\\Get-Variable").Select(value => value.BaseObject).OfType() + : Inspect($"Microsoft.PowerShell.Utility\\Get-Variable -Scope {scope}") + .Select(value => value.BaseObject).OfType(); + var scopedVariables = frameVariables.ToArray(); + var frameLocals = !IsRemote && frames.Length > 0 ? frames[frameIndex].GetFrameVariables() : new Dictionary(); + // Call-stack depth is not scope depth across dotted/module boundaries. + if (frameIndex > 0 && (!frameLocals.TryGetValue("MyInvocation", out var expected) || + !ReferenceEquals(expected.Value, scopedVariables.FirstOrDefault(variable => variable.Name.Equals("MyInvocation", StringComparison.OrdinalIgnoreCase))?.Value))) + throw new InvalidOperationException("PowerShell does not expose the selected frame's variable scope. Select another frame."); + debugValues.Clear(); + frameVariables = scopedVariables.Concat(frameLocals.Values).DistinctBy(variable => variable.Name, StringComparer.OrdinalIgnoreCase); + var variables = IsRemote ? remoteVariables + .OrderBy(value => value.Properties["Name"]?.Value?.ToString(), StringComparer.OrdinalIgnoreCase) + .Select(value => DescribeValue("$" + value.Properties["Name"]?.Value, value.Properties["Value"]?.Value)).ToArray() : frameVariables + .OrderBy(variable => variable.Name, StringComparer.OrdinalIgnoreCase) + .Select(variable => DescribeValue("$" + variable.Name, variable.Value)).ToArray(); + var stack = IsRemote ? Inspect(new PSCommand().AddCommand("Get-PSCallStack")).Select((frame, index) => + new DebugFrame(frame.Properties["FunctionName"].Value?.ToString() ?? "", + frame.Properties["ScriptName"].Value?.ToString(), + Convert.ToInt32(frame.Properties["ScriptLineNumber"].Value), index)).ToArray() : frames.Select((frame, index) => + new DebugFrame(frame.FunctionName, frame.ScriptName, frame.ScriptLineNumber, index)).ToArray(); + var values = expressions.Select(expression => + { + try + { + var results = Inspect(expression); + return results.Length == 0 ? DescribeValue(expression, null) : + results.Length == 1 ? DescribeValue(expression, results[0]) : + DescribeValue(expression, results); + } + catch (Exception exception) when (exception is RuntimeException or InvalidOperationException) + { + return new DebugValue(expression, "", "", exception.Message); + } + }).ToArray(); + return new DebugSnapshot(variables, values, stack); + }); + } + + private DebugValue DescribeValue(string name, object? value) + { + if (value is null) return new(name, "$null", ""); + var wrapped = PSObject.AsPSObject(value); + var underlying = wrapped.BaseObject; + var type = underlying.GetType(); + var expandable = underlying is IList or IDictionary || + !(type.IsPrimitive || type.IsEnum || underlying is string or decimal or DateTime or DateTimeOffset or TimeSpan or Guid) && + wrapped.Properties.Any(); + long? reference = null; + if (expandable) + { + reference = ++nextDebugValue; + debugValues.Add(reference.Value, value); + } + try + { + var preview = value.ToString() ?? ""; + if (preview.Length > 512) preview = preview[..512] + "..."; + return new(name, preview, type.Name, Reference: reference); + } + catch (Exception exception) { return new(name, "", type.Name, exception.Message, reference); } + } + + public Task GetValueChildrenAsync(long reference, int offset = 0, int count = 100) => + PausedQueryAsync(() => + { + if (offset < 0 || count is < 1 or > 100) + throw new InvalidOperationException("Invalid debugger object page."); + if (!debugValues.TryGetValue(reference, out var value)) + throw new InvalidOperationException("This debugger value has expired. Refresh the inspector."); + var wrapped = PSObject.AsPSObject(value); + IEnumerable<(string Name, Func Read)> Members() + { + if (wrapped.BaseObject is IDictionary dictionary) + { + foreach (var key in dictionary.Keys) + { + var captured = key; + yield return ($"[{key}]", () => dictionary[captured]); + } + } + else if (wrapped.BaseObject is IList list) + { + for (var index = 0; index < list.Count; index++) + { + var captured = index; + yield return ($"[{index}]", () => list[captured]); + } + } + else + foreach (var property in wrapped.Properties.Where(property => property.IsGettable)) + yield return (property.Name, () => property.Value); + } + var page = Members().Skip(offset).Take(count + 1).ToArray(); + var children = page.Take(count).Select(member => + { + try { return DescribeValue(member.Name, member.Read()); } + catch (Exception exception) { return new DebugValue(member.Name, "", "", exception.Message); } + }).ToArray(); + return new DebugChildren(children, page.Length > count ? offset + count : null); + }); + + private Task CompletePausedAsync(string text, int cursor, CancellationToken cancellationToken) => + PausedQueryAsync(() => + { + var command = new PSCommand().AddCommand("TabExpansion2").AddArgument(text).AddArgument(cursor); + var result = Inspect(command).Select(value => value.BaseObject).OfType().FirstOrDefault() + ?? throw new InvalidOperationException("PowerShell completion did not return a completion result."); + return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.ToArray()); + }, cancellationToken); + + public Task> GetBreakpointsAsync() => + BreakpointQueryAsync>(() => runspace.Debugger.GetBreakpoints() + .Select(DescribeBreakpoint).OrderBy(breakpoint => breakpoint.Id).ToArray(), mutation: false); + + public Task AddBreakpointAsync(BreakpointSpec spec) + { + spec.Validate(); + return BreakpointQueryAsync(() => DescribeBreakpoint(CreateBreakpoint(spec))); + } + + public Task UpdateBreakpointAsync(int id, BreakpointSpec spec) + { + spec.Validate(); + return BreakpointQueryAsync(() => + { + var previous = FindBreakpoint(id); + var replacement = CreateBreakpoint(spec); + runspace.Debugger.RemoveBreakpoint(previous); + breakpointSpecs.Remove(id); + return DescribeBreakpoint(replacement); + }); + } + + public Task SetBreakpointEnabledAsync(int id, bool enabled) => BreakpointQueryAsync(() => + { + var breakpoint = FindBreakpoint(id); + if (enabled) runspace.Debugger.EnableBreakpoint(breakpoint); + else runspace.Debugger.DisableBreakpoint(breakpoint); + if (breakpointSpecs.TryGetValue(id, out var spec)) breakpointSpecs[id] = spec with { Enabled = enabled }; + return true; + }); + + public Task RemoveBreakpointAsync(int id) => BreakpointQueryAsync(() => + { + runspace.Debugger.RemoveBreakpoint(FindBreakpoint(id)); + breakpointSpecs.Remove(id); + return true; + }); + + public Task RemoveAllBreakpointsAsync() => BreakpointQueryAsync(() => + { + foreach (var breakpoint in runspace.Debugger.GetBreakpoints()) runspace.Debugger.RemoveBreakpoint(breakpoint); + breakpointSpecs.Clear(); + return true; + }); + + public Task SetLineBreakpointsAsync(string path, IEnumerable specs) + { + var requested = specs.Select(spec => spec with { Kind = BreakpointKind.Line, ScriptPath = path }).ToArray(); + foreach (var spec in requested) spec.Validate(); + if (requested.DistinctBy(spec => spec.Line).Count() != requested.Length) + throw new ArgumentException("Only one editor breakpoint can be specified per line."); + return BreakpointQueryAsync(() => + { + var existing = runspace.Debugger.GetBreakpoints().OfType().Where(breakpoint => + string.Equals(breakpoint.Script, path, !IsRemote && OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)).ToList(); + var added = new List(); + try + { + foreach (var spec in requested) + { + var unchanged = existing.FirstOrDefault(breakpoint => DescribeBreakpoint(breakpoint).Spec == spec); + if (unchanged is not null) existing.Remove(unchanged); + else added.Add(CreateBreakpoint(spec)); + } + } + catch + { + foreach (var breakpoint in added) + { + runspace.Debugger.RemoveBreakpoint(breakpoint); + breakpointSpecs.Remove(breakpoint.Id); + } + throw; + } + foreach (var breakpoint in existing) + { + runspace.Debugger.RemoveBreakpoint(breakpoint); + breakpointSpecs.Remove(breakpoint.Id); + } + return true; + }); + } + + private Breakpoint FindBreakpoint(int id) => runspace.Debugger.GetBreakpoint(id) ?? + throw new InvalidOperationException($"Breakpoint #{id} no longer exists."); + + private static ScriptBlock? BreakpointAction(BreakpointSpec spec) => + !string.IsNullOrWhiteSpace(spec.Condition) ? ScriptBlock.Create($"if ({spec.Condition}) {{ break }}") : + !string.IsNullOrWhiteSpace(spec.Action) ? ScriptBlock.Create(spec.Action) : null; + + private Breakpoint CreateBreakpoint(BreakpointSpec spec) + { + spec.Validate(); + var action = BreakpointAction(spec); + var script = string.IsNullOrWhiteSpace(spec.ScriptPath) ? null : spec.ScriptPath; + Breakpoint breakpoint = spec.Kind switch + { + BreakpointKind.Line => runspace.Debugger.SetLineBreakpoint(script!, spec.Line, 0, action), + BreakpointKind.Command => runspace.Debugger.SetCommandBreakpoint(spec.Target, action, script), + BreakpointKind.Variable => runspace.Debugger.SetVariableBreakpoint(spec.Target.TrimStart('$'), spec.AccessMode, action, script), + _ => throw new ArgumentException("Invalid breakpoint kind.") + }; + if (!spec.Enabled) breakpoint = runspace.Debugger.DisableBreakpoint(breakpoint); + breakpointSpecs[breakpoint.Id] = spec; + return breakpoint; + } + + private DebugBreakpoint DescribeBreakpoint(Breakpoint breakpoint) + { + if (!breakpointSpecs.TryGetValue(breakpoint.Id, out var spec)) + spec = breakpoint switch + { + LineBreakpoint line => new(BreakpointKind.Line, line.Script, Line: line.Line, Action: line.Action?.ToString() ?? ""), + CommandBreakpoint command => new(BreakpointKind.Command, command.Script, command.Command, Action: command.Action?.ToString() ?? ""), + VariableBreakpoint variable => new(BreakpointKind.Variable, variable.Script, variable.Variable, AccessMode: variable.AccessMode, Action: variable.Action?.ToString() ?? ""), + _ => throw new InvalidOperationException("Unknown PowerShell breakpoint type.") + }; + return new(breakpoint.Id, spec with { Enabled = breakpoint.Enabled }, breakpoint.HitCount); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Nested.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Nested.cs new file mode 100644 index 0000000..87b573c --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Nested.cs @@ -0,0 +1,130 @@ +using System.Management.Automation; + +namespace Iseberg.Core; + +public sealed partial class PowerShellSession +{ + private readonly Stack nestedFrames = new(); + private sealed class NestedFrame + { + public Queue Work { get; } = new(); + public bool Exit { get; set; } + public bool Busy { get; set; } + } + public bool IsNestedPromptActive + { + get { lock (sync) return State == SessionState.NestedPrompt && nestedFrames.TryPeek(out var frame) && !frame.Exit && !frame.Busy && !stopRequested; } + } + public string NestedPrompt + { + get { lock (sync) return $"[Nested {nestedFrames.Count}]: PS> "; } + } + + private void EnterNestedPrompt() + { + if (IsRemote) + throw new PSNotSupportedException("Graphical nested prompts are local-only. Use remote breakpoints instead."); + if (State == SessionState.Debugging) + throw new PSNotSupportedException("Nested host prompts inside debugger evaluation are not supported. Use the debugger console directly."); + var frame = new NestedFrame(); + SessionState previous; + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + previous = State; + nestedFrames.Push(frame); + SetState(SessionState.NestedPrompt); + } + try + { + while (true) + { + DebugWork? work; + lock (sync) + { + if (frame.Exit || stopRequested) break; + work = frame.Work.TryDequeue(out var next) ? next : null; + } + if (work is null) debuggerWake.WaitOne(); + else work.Execute(); + } + lock (sync) + if (stopRequested) throw new PipelineStoppedException(); + } + finally + { + lock (sync) + { + nestedFrames.Pop(); + while (frame.Work.TryDequeue(out var work)) + work.Fail(new InvalidOperationException("The nested prompt has exited.")); + SetState(previous); + } + } + } + + private void ExitNestedPrompt() + { + lock (sync) + { + if (!nestedFrames.TryPeek(out var frame)) + throw new PSNotSupportedException("No nested prompt is active."); + frame.Exit = true; + debuggerWake.Set(); + } + } + + private Task NestedQueryAsync(Func query, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (!IsNestedPromptActive) throw new InvalidOperationException("No nested prompt is available."); + var frame = nestedFrames.Peek(); + frame.Busy = true; + StateChanged?.Invoke(State); + frame.Work.Enqueue(new(() => + { + T? value = default; + Exception? failure = null; + try + { + cancellationToken.ThrowIfCancellationRequested(); + // Nested pipelines must execute on the thread that called EnterNestedPrompt. + using var shell = PowerShell.Create(RunspaceMode.CurrentRunspace); + using var registration = cancellationToken.Register(() => shell.Stop()); + value = query(shell); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (Exception exception) { failure = exception; } + finally + { + lock (sync) + { + frame.Busy = false; + StateChanged?.Invoke(State); + } + } + if (cancellationToken.IsCancellationRequested) result.TrySetCanceled(cancellationToken); + else if (failure is not null) result.TrySetException(failure); + else result.TrySetResult(value!); + }, exception => result.TrySetException(exception))); + debuggerWake.Set(); + } + return result.Task.WaitAsync(cancellationToken); + } + + public Task EvaluateNestedAsync(string script) => NestedQueryAsync(shell => + { + var prompt = NestedPrompt; + Output?.Invoke(new(prompt + script + Environment.NewLine, OutputKind.Command, prompt.Length)); + shell.Streams.Error.DataAdded += (_, args) => + Output?.Invoke(new(shell.Streams.Error[args.Index].ToString() + Environment.NewLine, OutputKind.Error)); + try { shell.AddScript(script, useLocalScope: false).AddCommand("Out-Default").Invoke(); } + catch (RuntimeException exception) when (exception is not PipelineStoppedException) + { Output?.Invoke(new(exception.ErrorRecord.ToString() + Environment.NewLine, OutputKind.Error)); } + return true; + }); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Remoting.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Remoting.cs new file mode 100644 index 0000000..a02834b --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Remoting.cs @@ -0,0 +1,398 @@ +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Management.Automation.Runspaces; +using System.Reflection; +using System.Text.Json; + +namespace Iseberg.Core; + +public sealed partial class PowerShellSession +{ + private Runspace? pushedRunspace; + private bool ownsPushedRunspace; + private Runspace? connectingRunspace; + private readonly Dictionary> runspaceBreakpointSpecs = []; + public event Action? RunspaceChanged; + public bool IsRunspacePushed => pushedRunspace is not null; + public bool IsRemote => runspace.ConnectionInfo is not null; + public Guid RunspaceId => runspace.InstanceId; + public string? RemoteComputerName => runspace.ConnectionInfo?.ComputerName; + public string DebugPrompt => IsRemote ? $"[{RemoteComputerName}]: [DBG]: PS> " : "[DBG]: PS> "; + + public async Task ConnectAsync(RunspaceConnectionInfo connection, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + await gate.WaitAsync(cancellationToken); + Runspace? remote = null; + try + { + EnsureReady(); + if (IsRunspacePushed) throw new InvalidOperationException("Exit the current remote session before connecting."); + lock (sync) stopRequested = false; + SetState(SessionState.Running); + remote = RunspaceFactory.CreateRunspace(connection, host, TypeTable.LoadDefaultTypeFiles()); + lock (sync) connectingRunspace = remote; + using var registration = cancellationToken.Register(remote.CloseAsync); + await Task.Run(remote.Open, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PushRunspace(remote, true); + remote = null; + await Task.Run(RefreshPrompt); + } + finally + { + lock (sync) connectingRunspace = null; + remote?.Dispose(); + if (!disposed && State != SessionState.Starting) SetState(SessionState.Ready); + gate.Release(); + } + } + + public async Task ExitRemoteSessionAsync() + { + if (!await gate.WaitAsync(0)) throw new InvalidOperationException("Stop execution before leaving the remote session."); + var started = false; + try + { + EnsureReady(); + if (!IsRunspacePushed) throw new InvalidOperationException("No interactive session is active."); + lock (sync) stopRequested = false; + started = true; + SetState(SessionState.Running); + PopRunspace(); + await Task.Run(RefreshPrompt); + } + finally + { + if (started && !disposed) SetState(SessionState.Ready); + gate.Release(); + } + } + + private void PushRunspace(Runspace remote, bool owned) + { + ArgumentNullException.ThrowIfNull(remote); + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (IsRunspacePushed) throw new InvalidOperationException("Nested interactive sessions are not supported. Exit the current session first."); + if (remote.RunspaceStateInfo.State != RunspaceState.Opened) + throw new InvalidOperationException("Only an opened runspace can be pushed."); + owned |= HostOwnsRunspace(remote); + remote.Debugger.SetDebugMode(DebugModes.LocalScript | DebugModes.RemoteScript); + remote.Debugger.DebuggerStop += OnDebuggerStop; + remote.StateChanged += OnRemoteStateChanged; + SaveRunspaceBreakpoints(); + pushedRunspace = remote; + ownsPushedRunspace = owned; + RestoreRunspaceBreakpoints(); + debugValues.Clear(); + } + RunspaceChanged?.Invoke(); + } + + private void PopRunspace() + { + Runspace remote; + bool owned; + lock (sync) + { + remote = pushedRunspace ?? throw new InvalidOperationException("No interactive session is active."); + remote.Debugger.DebuggerStop -= OnDebuggerStop; + remote.StateChanged -= OnRemoteStateChanged; + SaveRunspaceBreakpoints(); + owned = ownsPushedRunspace; + pushedRunspace = null; + ownsPushedRunspace = false; + RestoreRunspaceBreakpoints(); + debugValues.Clear(); + } + if (owned) + { + runspaceBreakpointSpecs.Remove(remote.InstanceId); + remote.Dispose(); + } + RunspaceChanged?.Invoke(); + } + + private static bool HostOwnsRunspace(Runspace remote) + { + if (remote.ConnectionInfo is null) return false; + // The SDK's ownership flag is internal; reusable PSSession runspaces must remain open. + var ownership = remote.GetType().GetProperty("ShouldCloseOnPop", BindingFlags.Instance | BindingFlags.NonPublic); + return ownership?.GetValue(remote) is bool owned ? owned : + throw new PSNotSupportedException("This PowerShell SDK does not expose interactive runspace ownership."); + } + + private void SaveRunspaceBreakpoints() => + runspaceBreakpointSpecs[RunspaceId] = new(breakpointSpecs); + + private void RestoreRunspaceBreakpoints() + { + breakpointSpecs.Clear(); + if (runspaceBreakpointSpecs.TryGetValue(RunspaceId, out var saved)) + foreach (var entry in saved) breakpointSpecs.Add(entry.Key, entry.Value); + } + + private async void OnRemoteStateChanged(object? sender, RunspaceStateEventArgs args) + { + if (sender is not Runspace remote || args.RunspaceStateInfo.State is not + (RunspaceState.Broken or RunspaceState.Closed or RunspaceState.Disconnected)) return; + Output?.Invoke(new($"Remote connection ended: {args.RunspaceStateInfo.Reason?.Message ?? args.RunspaceStateInfo.State.ToString()}{Environment.NewLine}", OutputKind.Error)); + await gate.WaitAsync(); + try + { + if (disposed || pushedRunspace != remote) return; + SetState(SessionState.Running); + PopRunspace(); + await Task.Run(RefreshPrompt); + SetState(SessionState.Ready); + } + catch (Exception exception) when (exception is RuntimeException or InvalidOperationException) + { + Output?.Invoke(new($"Could not restore the local session: {exception.Message}{Environment.NewLine}", OutputKind.Error)); + } + finally { gate.Release(); } + } + + private static bool IsExitSessionCommand(string script) + { + var ast = Parser.ParseInput(script, out _, out var errors); + return errors.Length == 0 && ast.EndBlock?.Statements is { Count: 1 } statements && + (statements[0] is ExitStatementAst || + statements[0] is PipelineAst { PipelineElements.Count: 1 } pipeline && + pipeline.PipelineElements[0] is CommandAst { CommandElements.Count: 1 } command && + (string.Equals(command.GetCommandName(), "Exit-PSSession", StringComparison.OrdinalIgnoreCase) || + string.Equals(command.GetCommandName(), "Exit-PSHostProcess", StringComparison.OrdinalIgnoreCase))); + } + + private void EnsureRemoteFile(ScriptFile file) + { + if (!IsRemote || file.RemoteRunspaceId != RunspaceId) + throw new InvalidOperationException("This document belongs to another remote connection. Reconnect and reopen it before saving or running it."); + } + + public Task OpenRemoteFileAsync(string path) => OpenRemoteFileAsync(path, null); + + public Task OpenRemoteFileAsync(string path, ScriptEncoding? choice = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!IsRemote) throw new InvalidOperationException("Connect to a remote session before opening remote files."); + var id = RunspaceId; + var computer = RemoteComputerName!; + var command = new PSCommand().AddScript(""" + param($path) + $ErrorActionPreference = 'Stop' + $item = Get-Item -LiteralPath $path + if ($item.PSProvider.Name -ne 'FileSystem' -or $item.PSIsContainer) { throw 'Select a filesystem file.' } + [pscustomobject]@{ Path = $item.FullName; Content = [Convert]::ToBase64String([IO.File]::ReadAllBytes($item.FullName)) } + """, useLocalScope: true).AddArgument(path); + return FileQueryAsync(command, values => + { + if (RunspaceId != id) throw new InvalidOperationException("The remote connection changed while opening this file."); + var value = values.Single(); + return ScriptFile.FromRemoteBytes(value.Properties["Path"].Value.ToString()!, + Convert.FromBase64String(value.Properties["Content"].Value.ToString()!), id, computer, choice); + }, id); + } + + public Task SaveRemoteFileAsync(ScriptFile file, string? path = null) => + SaveRemoteFileCoreAsync(file, path, file.SavedVersion, useSavedVersion: true); + + // Explicit consent: null requires absence; a hash authorizes only that server-side content. + public Task SaveRemoteFileAsync(ScriptFile file, string? path, string? expectedVersion) => + SaveRemoteFileCoreAsync(file, path, expectedVersion, useSavedVersion: false); + + private async Task SaveRemoteFileCoreAsync(ScriptFile file, string? path, string? expectedVersion, bool useSavedVersion) + { + if (file.IsRemote) EnsureRemoteFile(file); + else if (!IsRemote || file.Path is not null) + throw new InvalidOperationException("Only remote documents or untitled scripts can be saved to the remote session."); + ArgumentException.ThrowIfNullOrWhiteSpace(path ?? file.Path); + var id = RunspaceId; + var computer = RemoteComputerName!; + var snapshot = file.Text; + var snapshotEncoding = file.EncodingChoice; + var bytes = ScriptFile.Encode(snapshot, snapshotEncoding); + var command = new PSCommand().AddScript(""" + param($path, $content, $expectedVersion, $originalPath, $useSavedVersion) + $ErrorActionPreference = 'Stop' + $provider = $null + $drive = $null + $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($path, [ref]$provider, [ref]$drive) + if ($provider.Name -ne 'FileSystem') { throw 'Select a filesystem file.' } + if ($useSavedVersion) { + $samePath = $false + if ($originalPath) { + $originalFull = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($originalPath) + $comparison = if ([Environment]::OSVersion.Platform -eq 'Win32NT') { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $samePath = [string]::Equals($originalFull, $full, $comparison) + } + if (!$samePath) { $expectedVersion = $null } + } + $temp = $full + '.' + [guid]::NewGuid().ToString('N') + '.tmp' + $current = $null + try { + [IO.File]::WriteAllBytes($temp, [Convert]::FromBase64String($content)) + try { + $current = [IO.File]::Open($full, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::Read -bor [IO.FileShare]::Delete) + } catch [IO.FileNotFoundException] { } catch [IO.DirectoryNotFoundException] { } + $actualVersion = $null + if ($current) { + $sha = [Security.Cryptography.SHA256]::Create() + try { $actualVersion = [BitConverter]::ToString($sha.ComputeHash($current)).Replace('-', '') } + finally { $sha.Dispose() } + } + $sameVersion = if ($null -eq $expectedVersion) { $null -eq $actualVersion } + else { $null -ne $actualVersion -and [string]::Equals($expectedVersion, $actualVersion, [StringComparison]::OrdinalIgnoreCase) } + if (!$sameVersion) { + [pscustomobject]@{ Path = $full; Conflict = $true; ExpectedVersion = $expectedVersion; ActualVersion = $actualVersion } + return + } + if ($current) { + if ($IsLinux -or $IsMacOS) { [IO.File]::SetUnixFileMode($temp, [IO.File]::GetUnixFileMode($full)) } + [IO.File]::Replace($temp, $full, [System.Management.Automation.Language.NullString]::Value) + } else { [IO.File]::Move($temp, $full) } + [pscustomobject]@{ Path = $full; Conflict = $false } + } finally { + if ($current) { $current.Dispose() } + if ([IO.File]::Exists($temp)) { [IO.File]::Delete($temp) } + } + """, useLocalScope: true).AddArgument(path ?? file.Path).AddArgument(Convert.ToBase64String(bytes)) + .AddArgument(expectedVersion).AddArgument(file.IsRemote ? file.Path : null).AddArgument(useSavedVersion); + var savedPath = await FileQueryAsync(command, values => + { + var result = values.Single(); + var savedPath = result.Properties["Path"].Value.ToString()!; + if (LanguagePrimitives.ConvertTo(result.Properties["Conflict"].Value)) + throw new FileConflictException(savedPath, result.Properties["ExpectedVersion"].Value?.ToString(), + result.Properties["ActualVersion"].Value?.ToString()); + return savedPath; + }, id); + file.MarkRemoteSaved(savedPath, snapshot, snapshotEncoding, ScriptFile.GetVersion(bytes), id, computer); + } + + private Task FileQueryAsync(PSCommand command, Func read, Guid? expectedRunspace = null) => + IsDebuggerPaused ? PausedQueryAsync(() => + { + CheckFileRunspace(expectedRunspace); + return read(Inspect(command)); + }) : QueryAsync(shell => + { + CheckFileRunspace(expectedRunspace); + shell.Commands = command; + var values = shell.Invoke(); + ThrowQueryErrors(shell); + return read(values.ToArray()); + }, waitForGate: true); + + private void CheckFileRunspace(Guid? expected) + { + if (!IsRemote || expected is { } id && RunspaceId != id) + throw new InvalidOperationException("The remote connection changed. Reopen the file in the active connection."); + } + + public Task RemoteFileExistsAsync(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + return FileQueryAsync(new PSCommand().AddCommand("Test-Path").AddParameter("LiteralPath", path), + values => LanguagePrimitives.ConvertTo(values.Single()), RunspaceId); + } + + public Task GetRemoteFileVersionAsync(string path) => ReadRemoteFileVersionAsync(path); + + public Task ReadRemoteFileVersionAsync(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + var command = new PSCommand().AddScript(""" + param($path) + $ErrorActionPreference = 'Stop' + $provider = $null + $drive = $null + $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($path, [ref]$provider, [ref]$drive) + if ($provider.Name -ne 'FileSystem') { throw 'Select a filesystem file.' } + $current = $null + try { + try { + $current = [IO.File]::Open($full, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]::Read -bor [IO.FileShare]::Delete) + } catch [IO.FileNotFoundException] { } catch [IO.DirectoryNotFoundException] { } + $version = $null + if ($current) { + $sha = [Security.Cryptography.SHA256]::Create() + try { $version = [BitConverter]::ToString($sha.ComputeHash($current)).Replace('-', '') } + finally { $sha.Dispose() } + } + [pscustomobject]@{ Version = $version } + } finally { if ($current) { $current.Dispose() } } + """, useLocalScope: true).AddArgument(path); + return FileQueryAsync(command, values => values.Single().Properties["Version"].Value?.ToString(), RunspaceId); + } + + public async Task HasRemoteFileChangesAsync(ScriptFile file) + { + EnsureRemoteFile(file); + return !ScriptFile.VersionsMatch(file.SavedVersion, await ReadRemoteFileVersionAsync(file.Path!)); + } + + private static CommandFormDescription GetRemoteCommandForm(PowerShell shell, string name, string? module) + { + // Project metadata on the server: deserialized CommandInfo cannot be cast to the local SDK type. + shell.AddScript(""" + param($name, $module) + $ErrorActionPreference = 'Stop' + $parameters = @{ Name = [System.Management.Automation.WildcardPattern]::Escape($name) } + if ($module) { $parameters.Module = $module } + $command = Get-Command @parameters | Where-Object Name -EQ $name | Select-Object -First 1 + if (!$command) { throw "Command '$name' was not found." } + $resolved = $command + while ($resolved -is [System.Management.Automation.AliasInfo]) { $resolved = $resolved.ResolvedCommand } + if (!$resolved) { throw "Cannot resolve alias '$name'." } + $common = @('Verbose','Debug','ErrorAction','WarningAction','InformationAction','ProgressAction', + 'ErrorVariable','WarningVariable','InformationVariable','OutVariable','OutBuffer','PipelineVariable','WhatIf','Confirm') + $sets = @($resolved.ParameterSets | ForEach-Object { + $set = $_ + $items = @($set.Parameters | Sort-Object @{Expression='IsMandatory';Descending=$true}, + @{Expression={if ($_.Position -lt 0) { [int]::MaxValue } else { $_.Position }}}, Name | ForEach-Object { + $p = $_ + $type = $p.ParameterType + $element = if ($type.IsArray) { $type.GetElementType() } else { $type } + $nullable = [Nullable]::GetUnderlyingType($element) + if ($nullable) { $element = $nullable } + $validation = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | Select-Object -First 1 + $choices = @() + if ($validation) { $choices = @($validation.ValidValues) } + elseif ($element.IsEnum) { $choices = @([Enum]::GetNames($element)) } + elseif ($type.IsArray -and $element -eq [bool]) { $choices = @('True','False') } + $kind = if (!$type.IsArray -and $element -eq [System.Management.Automation.SwitchParameter]) { 1 } + elseif ($element -eq [bool]) { 2 } elseif ($choices.Count) { 3 } else { 0 } + $help = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.HelpMessage } | Select-Object -First 1 + [ordered]@{ + Name=$p.Name; TypeName=$type.Name; Kind=$kind; IsMandatory=$p.IsMandatory + Position=$(if ($p.Position -lt 0) { $null } else { $p.Position }); IsArray=$type.IsArray + IsCommon=($p.Name -in $common) + AllowsEmptyString=[bool]@($p.Attributes | Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] }).Count + AcceptsPipelineInput=($p.ValueFromPipeline -or $p.ValueFromPipelineByPropertyName) + HelpMessage=$(if ($help) { $help.HelpMessage } else { '' }) + Aliases=@($p.Aliases); Choices=$choices + ChoicesIgnoreCase=$(if ($validation) { $validation.IgnoreCase } else { $true }) + } + }) + [ordered]@{ Name=$set.Name; IsDefault=$set.IsDefault; Parameters=$items } + }) + if (!$sets.Count) { $sets = @(@{Name='__AllParameterSets';IsDefault=$true;Parameters=@()}) } + [ordered]@{ + Name=$command.Name + InvocationName=$(if ($command.ModuleName) { $command.ModuleName + '\' + $command.Name } else { $command.Name }) + ParameterSets=$sets + } | ConvertTo-Json -Depth 12 -Compress + """, useLocalScope: true).AddArgument(name).AddArgument(module); + var json = shell.Invoke(); + ThrowQueryErrors(shell); + return JsonSerializer.Deserialize(json.Single()) + ?? throw new InvalidOperationException("Remote command metadata was empty."); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs new file mode 100644 index 0000000..54e6238 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs @@ -0,0 +1,580 @@ +using System.Management.Automation; +using System.Management.Automation.Runspaces; + +namespace Iseberg.Core; + +/// Owns a persistent PowerShell runspace with serialized execution, completion, and debugging. +public sealed partial class PowerShellSession : IAsyncDisposable +{ + private readonly SemaphoreSlim gate = new(1, 1); + private readonly object sync = new(); + private readonly AutoResetEvent debuggerWake = new(false); + private readonly Runspace localRunspace; + private readonly WorkbenchHost host; + private Runspace runspace => pushedRunspace ?? localRunspace; + private PowerShell? active; + private InputRequest? pendingInput; + private ShowCommandRequest? pendingShowCommand; + private CommandErrorRequest? pendingCommandError; + private DebuggerResumeAction resumeAction; + private bool disposed; + private Task? disposalTask; + private bool stopRequested; + private object? iseObjectModel; + public IseSnippetService Snippets { get; } + + /// Formatted stream entries, including script errors; callbacks may occur on pipeline threads. + public event Action? Output; + /// Execution-state transitions; marshal callbacks before updating a UI. + public event Action? StateChanged; + /// Interactive input requiring a host response; do not block or re-enter the engine in the handler. + public event Action? InputRequested; + public event Action? ShowCommandRequested; + public event Action? CommandErrorRequested; + public event Action? ProgressChanged; + public event Action? DebuggerStopped; + public event Action? ConsoleCleared; + public SessionState State { get; private set; } = SessionState.Starting; + public string Prompt { get; private set; } = "PS> "; + public string Version => PSVersionInfo.PSVersion.ToString(); + public Guid LocalRunspaceId => localRunspace.InstanceId; + public void SetTerminalSize(int columns, int rows) => host.SetTerminalSize(columns, rows); + + /// Creates an unopened local runspace with optional custom snippet storage. + public PowerShellSession(string? snippetDirectory = null) + { + Snippets = new(snippetDirectory); + host = new WorkbenchHost(entry => Output?.Invoke(entry), ReadInput, + update => ProgressChanged?.Invoke(update), () => ConsoleCleared?.Invoke(), ReadShowCommand, + () => runspace, () => IsRunspacePushed, remote => PushRunspace(remote, false), PopRunspace, + EnterNestedPrompt, ExitNestedPrompt, ReadCommandError, Snippets); + var initialState = InitialSessionState.CreateDefault2(); + // The default Unix function clears a terminal instead of this graphical host. + initialState.Commands.Remove("Clear-Host", typeof(SessionStateFunctionEntry)); + initialState.Commands.Add(new SessionStateFunctionEntry("Clear-Host", """ + $rawUI = $Host.UI.RawUI + $rawUI.SetBufferContents( + [System.Management.Automation.Host.Rectangle]::new(-1, -1, -1, -1), + [System.Management.Automation.Host.BufferCell]::new( + ' ', + $rawUI.ForegroundColor, + $rawUI.BackgroundColor, + [System.Management.Automation.Host.BufferCellType]::Complete)) + """)); + initialState.Commands.Remove("clear", typeof(SessionStateAliasEntry)); + initialState.Commands.Add(new SessionStateAliasEntry("clear", "Clear-Host")); + // These binary cmdlets must be available even when Restricted prevents loading module type data. + initialState.Commands.Add(new SessionStateCmdletEntry("Set-ExecutionPolicy", typeof(Microsoft.PowerShell.Commands.SetExecutionPolicyCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Get-ExecutionPolicy", typeof(Microsoft.PowerShell.Commands.GetExecutionPolicyCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Show-IsebergCommand", typeof(ShowCommandCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Start-IsebergTerminal", typeof(StartTerminalCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("New-IseSnippet", typeof(NewIseSnippetCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Get-IseSnippet", typeof(GetIseSnippetCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Import-IseSnippet", typeof(ImportIseSnippetCommand), null)); + // A function keeps precedence when Utility is auto-imported by commands such as Get-Help. + initialState.Commands.Remove("Show-Command", typeof(SessionStateFunctionEntry)); + initialState.Commands.Add(new SessionStateFunctionEntry("Show-Command", """ + [CmdletBinding()] + param( + [Parameter(Position=0)] [ValidateNotNullOrEmpty()] [string] $Name, + [switch] $PassThru, + [switch] $NoCommonParameter, + [switch] $ErrorPopup, + [ValidateRange(300, [int]::MaxValue)] [int] $Width = 360, + [ValidateRange(300, [int]::MaxValue)] [int] $Height = 410 + ) + Show-IsebergCommand @PSBoundParameters + """)); + localRunspace = RunspaceFactory.CreateRunspace(host, initialState); + runspace.ThreadOptions = PSThreadOptions.ReuseThread; + if (OperatingSystem.IsWindows()) + runspace.ApartmentState = ApartmentState.STA; + } + + public void ConfigureIseObjectModel(object model) + { + ArgumentNullException.ThrowIfNull(model); + if (!gate.Wait(0)) throw new InvalidOperationException("Configure the ISE object model before executing commands."); + try + { + ObjectDisposedException.ThrowIf(disposed, this); + if (State is not (SessionState.Starting or SessionState.Ready)) + throw new InvalidOperationException("Configure the ISE object model before executing commands."); + iseObjectModel = model; + if (localRunspace.RunspaceStateInfo.State == RunspaceState.Opened) + localRunspace.SessionStateProxy.SetVariable("psISE", model); + } + finally { gate.Release(); } + } + + /// Opens the local runspace and prepares its host. Profiles are not loaded automatically. + public async Task InitializeAsync() + { + await gate.WaitAsync(); + try + { + ObjectDisposedException.ThrowIf(disposed, this); + if (State != SessionState.Starting || runspace.RunspaceStateInfo.State != RunspaceState.BeforeOpen) + throw new InvalidOperationException("This PowerShell tab has already been initialized."); + await Task.Run(() => + { + runspace.Open(); + runspace.Debugger.SetDebugMode(DebugModes.LocalScript); + runspace.Debugger.DebuggerStop += OnDebuggerStop; + var config = OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "powershell"); + var profile = PSObject.AsPSObject(Path.Combine(config, "Devolutions.Terminal.Iseberg_profile.ps1")); + profile.Properties.Add(new PSNoteProperty("CurrentUserCurrentHost", profile.BaseObject)); + profile.Properties.Add(new PSNoteProperty("CurrentUserAllHosts", Path.Combine(config, "profile.ps1"))); + var engineHome = Path.GetDirectoryName(typeof(PSObject).Assembly.Location)!; + profile.Properties.Add(new PSNoteProperty("AllUsersCurrentHost", Path.Combine(engineHome, "Devolutions.Terminal.Iseberg_profile.ps1"))); + profile.Properties.Add(new PSNoteProperty("AllUsersAllHosts", Path.Combine(engineHome, "profile.ps1"))); + runspace.SessionStateProxy.SetVariable("PROFILE", profile); + if (iseObjectModel is not null) runspace.SessionStateProxy.SetVariable("psISE", iseObjectModel); + RefreshPrompt(); + }); + SetState(SessionState.Ready); + } + finally { gate.Release(); } + } + + public async Task SetWorkingDirectoryAsync(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!Path.IsPathFullyQualified(path)) throw new ArgumentException("An absolute directory is required.", nameof(path)); + await gate.WaitAsync(); + try + { + EnsureReady(); + await Task.Run(() => + { + localRunspace.SessionStateProxy.Path.SetLocation(path); + RefreshPrompt(); + }); + } + finally { gate.Release(); } + } + + /// Executes text or a named script in the persistent session. Script errors are delivered through Output. + public Task ExecuteAsync(string script, string? filePath = null) => ExecuteAsync(script, filePath, null); + + public Task ExecuteMenuActionAsync(ScriptBlock action) + { + ArgumentNullException.ThrowIfNull(action); + if (IsRunspacePushed) throw new PSNotSupportedException("ISE Add-ons menu actions require their original local runspace. Exit the remote session first."); + return ExecuteAsync("ISE Add-ons menu action", null, action); + } + + private async Task ExecuteAsync(string script, string? filePath, ScriptBlock? action) + { + if (!await gate.WaitAsync(0)) + throw new InvalidOperationException("This PowerShell tab is busy."); + var started = false; + try + { + EnsureReady(); + lock (sync) stopRequested = false; + started = true; + SetState(SessionState.Running); + Output?.Invoke(new(Prompt + (filePath ?? script) + Environment.NewLine, OutputKind.Command, Prompt.Length)); + await Task.Run(() => + { + if (IsRunspacePushed && filePath is null && IsExitSessionCommand(script)) + { + PopRunspace(); + RefreshPrompt(); + return; + } + using var shell = CreateShell(); + try + { + if (action is not null) + // Keep the original block, including module/session state and GetNewClosure(). + shell.AddScript(". $args[0]", useLocalScope: false).AddArgument(action); + else if (filePath is null) + shell.AddScript(script, useLocalScope: false); + else + shell.AddScript(". '" + filePath.Replace("'", "''") + "'", useLocalScope: false); + shell.AddCommand("Out-Default"); + using var output = new PSDataCollection(); + IAsyncResult invocation; + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + invocation = shell.BeginInvoke(null, output); + active = shell; + } + shell.EndInvoke(invocation); + } + catch (PipelineStoppedException) + { + Output?.Invoke(new("Execution stopped." + Environment.NewLine, OutputKind.Warning)); + } + catch (RuntimeException exception) + { + Output?.Invoke(new(exception.ErrorRecord.ToString() + Environment.NewLine, OutputKind.Error)); + } + finally + { + lock (sync) active = null; + DebuggerStopped?.Invoke(null); + RefreshPrompt(); + } + }); + } + finally + { + if (started && !disposed) SetState(SessionState.Ready); + gate.Release(); + } + } + + /// Requests cancellation of execution, debugger evaluation, and pending interactive host requests. + public Task StopAsync() + { + lock (sync) + { + stopRequested = true; + pendingInput?.Response.TrySetCanceled(); + pendingShowCommand?.Response.TrySetCanceled(); + pendingCommandError?.Response.TrySetCanceled(); + foreach (var frame in nestedFrames) frame.Exit = true; + resumeAction = DebuggerResumeAction.Stop; + resumeRequested = true; + debuggerWake.Set(); + connectingRunspace?.CloseAsync(); + if (runspace.RunspaceStateInfo.State == RunspaceState.Opened) + runspace.Debugger?.StopProcessCommand(); + if (active is { } shell) + return shell.StopAsync(null, null); + } + return Task.CompletedTask; + } + + public void Resume(DebuggerResumeAction action) + { + lock (sync) + { + if (State != SessionState.Debugging) + throw new InvalidOperationException("The debugger is not paused."); + resumeAction = action; + resumeRequested = true; + debuggerWake.Set(); + } + } + + public Task SetBreakpointsAsync(string path, IEnumerable lines) => + SetLineBreakpointsAsync(path, lines.Select(line => new BreakpointSpec(BreakpointKind.Line, path, Line: line))); + + /// Completes text at a zero-based cursor offset in the current runspace or paused debugger context. + public Task CompleteAsync(string text, int cursor, CancellationToken cancellationToken = default) => + State == SessionState.NestedPrompt ? NestedQueryAsync(shell => + { + var result = CommandCompletion.CompleteInput(text, cursor, null, shell); + return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.ToArray()); + }, cancellationToken) : State == SessionState.Debugging ? CompletePausedAsync(text, cursor, cancellationToken) : QueryAsync(shell => + { + var result = CommandCompletion.CompleteInput(text, cursor, null, shell); + return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.ToArray()); + }, cancellationToken); + + public Task> GetCommandsAsync(CancellationToken cancellationToken = default) => + QueryAsync>(shell => + { + shell.AddCommand("Get-Command"); + if (IsRemote) + shell.AddCommand("Microsoft.PowerShell.Utility\\Select-Object") + .AddParameter("Property", new[] { "Name", "ModuleName", "CommandType", "Definition" }); + var commands = shell.Invoke(); + ThrowQueryErrors(shell); + return commands.Select(command => new CommandDescription( + command.Properties["Name"].Value.ToString()!, command.Properties["ModuleName"].Value?.ToString() ?? "", + command.Properties["CommandType"].Value.ToString()!, command.Properties["Definition"].Value?.ToString() ?? "")) + .OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase).DistinctBy(command => command.Name).ToArray(); + }, cancellationToken); + + internal static IReadOnlyList DescribeCommands(IEnumerable commands) => + commands.OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase) + .Select(command => new CommandDescription(command.Name, command.ModuleName, + command.CommandType.ToString(), command.Definition)) + .DistinctBy(command => command.Name).ToArray(); + + public Task GetHelpAsync(string name) => QueryAsync(shell => + { + var output = shell.AddCommand("Get-Help").AddParameter("Name", name).AddParameter("Full") + .AddCommand("Out-String").AddParameter("Width", 100).Invoke(); + ThrowQueryErrors(shell); + return string.Join(Environment.NewLine, output); + }); + + public Task GetHelpDocumentAsync(string name) => QueryAsync(shell => + { + var help = shell.AddCommand("Get-Help").AddParameter("Name", name).AddParameter("Full").Invoke(); + ThrowQueryErrors(shell); + return CommandHelpDocument.FromHelp(name, help); + }); + + public Task GetCommandFormAsync(string name, string? module = null, + CancellationToken cancellationToken = default) => QueryAsync(shell => + { + if (IsRemote) return GetRemoteCommandForm(shell, name, module); + shell.AddCommand("Get-Command").AddParameter("Name", WildcardPattern.Escape(name)); + if (!string.IsNullOrEmpty(module)) shell.AddParameter("Module", module); + var commands = shell.Invoke(); + ThrowQueryErrors(shell); + var command = commands.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Command '{name}' was not found in this PowerShell tab."); + return CommandForm.Describe(command); + }, cancellationToken, waitForGate: true); + + public Task GetHelpUriAsync(string name) => QueryAsync(shell => + { + var help = shell.AddCommand("Get-Help").AddParameter("Name", name).Invoke(); + ThrowQueryErrors(shell); + return FindHelpUri(help); + }); + + internal static Uri? FindHelpUri(IEnumerable help) + { + foreach (var entry in help) + { + if (entry.Properties["RelatedLinks"]?.Value is not PSObject links) continue; + if (links.Properties["navigationLink"]?.Value is not System.Collections.IEnumerable navigation) continue; + foreach (var link in navigation) + { + var value = PSObject.AsPSObject(link).Properties["uri"]?.Value?.ToString(); + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http") return uri; + } + } + return null; + } + + private async Task QueryAsync(Func query, CancellationToken cancellationToken = default, + bool waitForGate = false) + { + if (waitForGate) await gate.WaitAsync(cancellationToken); + else if (!await gate.WaitAsync(0)) + throw new InvalidOperationException("Wait for the running command to finish."); + try + { + EnsureReady(); + return await Task.Run(() => + { + using var shell = CreateShell(); + using var registration = cancellationToken.Register(() => shell.Stop()); + cancellationToken.ThrowIfCancellationRequested(); + var debugger = shell.Runspace.Debugger; + var debugMode = debugger.DebugMode; + debugger.SetDebugMode(DebugModes.None); + try + { + var result = query(shell); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + catch (PipelineStoppedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + finally { debugger.SetDebugMode(debugMode); } + }); + } + finally { gate.Release(); } + } + + private PowerShell CreateShell() + { + var shell = PowerShell.Create(); + shell.Runspace = runspace; + shell.Streams.Error.DataAdded += (_, e) => Output?.Invoke(new(shell.Streams.Error[e.Index].ToString() + Environment.NewLine, OutputKind.Error)); + return shell; + } + + private static void ThrowQueryErrors(PowerShell shell) + { + if (shell.HadErrors) + throw new InvalidOperationException(string.Join(Environment.NewLine, shell.Streams.Error.Select(e => e.ToString()))); + } + + private void EnsureReady() + { + ObjectDisposedException.ThrowIf(disposed, this); + if (State != SessionState.Ready) + throw new InvalidOperationException("The PowerShell tab is not ready."); + } + + private string ReadInput(InputRequest request) + { + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + pendingInput = request; + } + try + { + if (InputRequested is null) + throw new PSNotSupportedException("No input handler is attached to this host."); + InputRequested.Invoke(request); + return request.Response.Task.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { throw new PipelineStoppedException(); } + finally { lock (sync) pendingInput = null; } + } + + private string? ReadShowCommand(ShowCommandRequest request) + { + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + pendingShowCommand = request; + } + try + { + if (ShowCommandRequested is null) + throw new PSNotSupportedException("No Show-Command handler is attached to this host."); + ShowCommandRequested.Invoke(request); + return request.Response.Task.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { throw new PipelineStoppedException(); } + finally { lock (sync) pendingShowCommand = null; } + } + + private void OnDebuggerStop(object? sender, DebuggerStopEventArgs e) + { + var previousState = State; + lock (sync) + { + resumeRequested = stopRequested; + resumeAction = stopRequested ? DebuggerResumeAction.Stop : DebuggerResumeAction.Continue; + SetState(SessionState.Debugging); + } + + var invocation = e.InvocationInfo; + DebuggerStopped?.Invoke(new(invocation?.ScriptName, invocation?.ScriptLineNumber ?? 0, + invocation?.OffsetInLine ?? 0, invocation?.PositionMessage ?? "Execution paused without a script location.")); + try + { + while (true) + { + DebugWork? work; + lock (sync) + { + if (resumeRequested) break; + work = debugWork.Count > 0 ? debugWork.Dequeue() : null; + } + if (work is null) debuggerWake.WaitOne(); + else work.Execute(); + } + } + finally + { + lock (sync) + { + e.ResumeAction = resumeAction; + debugValues.Clear(); + while (debugWork.TryDequeue(out var work)) + work.Fail(new InvalidOperationException("The debugger has resumed.")); + } + } + SetState(previousState); + DebuggerStopped?.Invoke(null); + } + + private void ReadCommandError(string message) + { + var request = new CommandErrorRequest(message); + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + pendingCommandError = request; + } + try + { + if (CommandErrorRequested is null) + throw new PSNotSupportedException("No command error popup handler is attached to this host." + Environment.NewLine + message); + CommandErrorRequested.Invoke(request); + request.Response.Task.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { throw new PipelineStoppedException(); } + finally { lock (sync) pendingCommandError = null; } + } + + private void RefreshPrompt() + { + using var shell = PowerShell.Create(); + shell.Runspace = runspace; + shell.AddScript("prompt", useLocalScope: true); + var debugger = shell.Runspace.Debugger; + var debugMode = debugger.DebugMode; + debugger.SetDebugMode(DebugModes.None); + try + { + IAsyncResult invocation; + lock (sync) + { + if (stopRequested) + { + Prompt = IsRemote ? $"[{RemoteComputerName}]: PS> " : "PS> "; + return; + } + invocation = shell.BeginInvoke(); + active = shell; + } + var values = shell.EndInvoke(invocation); + if (shell.HadErrors) + { + Output?.Invoke(new("The prompt function failed: " + string.Join("; ", shell.Streams.Error) + Environment.NewLine, OutputKind.Error)); + Prompt = "PS> "; + } + else + Prompt = string.Concat(values.Select(v => v.ToString())); + } + catch (PipelineStoppedException) + { + Prompt = "PS> "; + Output?.Invoke(new("Prompt evaluation stopped." + Environment.NewLine, OutputKind.Warning)); + } + catch (RuntimeException exception) + { + Prompt = "PS> "; + Output?.Invoke(new("The prompt function failed: " + exception.Message + Environment.NewLine, OutputKind.Error)); + } + finally + { + lock (sync) active = null; + debugger.SetDebugMode(debugMode); + } + if (IsRemote) Prompt = $"[{RemoteComputerName}]: " + Prompt; + } + + private void SetState(SessionState state) + { + State = state; + StateChanged?.Invoke(state); + } + + /// Stops execution and releases the runspace. Repeated and concurrent calls await the same disposal. + public ValueTask DisposeAsync() + { + lock (sync) return new ValueTask(disposalTask ??= DisposeCoreAsync()); + } + + private async Task DisposeCoreAsync() + { + await StopAsync(); + await gate.WaitAsync(); + try + { + if (disposed) return; + disposed = true; + if (pushedRunspace is not null) PopRunspace(); + if (localRunspace.Debugger is { } debugger) debugger.DebuggerStop -= OnDebuggerStop; + await Task.Run(localRunspace.Dispose); + iseObjectModel = null; + debuggerWake.Dispose(); + SetState(SessionState.Disposed); + } + finally { gate.Release(); } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ProgressTracker.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ProgressTracker.cs new file mode 100644 index 0000000..f91ebe7 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ProgressTracker.cs @@ -0,0 +1,35 @@ +namespace Iseberg.Core; + +public sealed class ProgressTracker +{ + private readonly Dictionary<(long Source, int Activity), ProgressUpdate> activities = []; + public IReadOnlyList Active => activities.Values.ToArray(); + + public void Update(ProgressUpdate progress) + { + ArgumentNullException.ThrowIfNull(progress); + if (progress.ActivityId < 0 || progress.ParentActivityId == progress.ActivityId || progress.Percent is < -1 or > 100) + throw new ArgumentException("Invalid progress identity, hierarchy, or percentage.", nameof(progress)); + var key = (progress.SourceId, progress.ActivityId); + if (!progress.Completed) { activities[key] = progress; return; } + var removed = new HashSet { progress.ActivityId }; + while (activities.Values.Any(activity => activity.SourceId == progress.SourceId && + removed.Contains(activity.ParentActivityId) && removed.Add(activity.ActivityId))) { } + foreach (var activity in removed) activities.Remove((progress.SourceId, activity)); + } + + public int Depth(ProgressUpdate progress) + { + var depth = 0; + var visited = new HashSet { progress.ActivityId }; + while (progress.ParentActivityId >= 0 && visited.Add(progress.ParentActivityId) && + activities.TryGetValue((progress.SourceId, progress.ParentActivityId), out var parent)) + { + depth++; + progress = parent; + } + return depth; + } + + public void Clear() => activities.Clear(); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ReleaseUpdateChecker.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ReleaseUpdateChecker.cs new file mode 100644 index 0000000..443d802 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ReleaseUpdateChecker.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Iseberg.Core; + +public sealed record ReleaseUpdate(string Version, Uri ReleaseUri, Uri DownloadUri, bool HasPackage); + +public sealed class ReleaseUpdateChecker(HttpClient client) +{ + public const string RepositoryUrl = "https://github.com/adamdriscoll/iseberg"; + public static Uri LatestReleaseApi { get; } = new("https://api.github.com/repos/adamdriscoll/iseberg/releases/latest"); + + public async Task CheckAsync(string currentVersion, string runtime, CancellationToken cancellationToken = default) + { + var current = ParseVersion(currentVersion); + using var request = new HttpRequestMessage(HttpMethod.Get, LatestReleaseApi); + request.Headers.UserAgent.ParseAdd("Iseberg/" + current.Numeric); + request.Headers.Accept.ParseAdd("application/vnd.github+json"); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + var release = await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidDataException("GitHub returned an empty release."); + if (release.Draft || release.Prerelease) return null; + if (release.Assets is null || release.Assets.Any(asset => asset is null)) + throw new InvalidDataException("GitHub returned invalid release assets."); + var latest = ParseVersion(release.Tag); + if (latest.IsPrerelease) return null; + if (latest.Numeric < current.Numeric || (latest.Numeric == current.Numeric && !current.IsPrerelease)) return null; + + var releaseUri = new Uri($"{RepositoryUrl}/releases/tag/{Uri.EscapeDataString(release.Tag)}"); + var formats = runtime.StartsWith("win-", StringComparison.Ordinal) ? new[] { "msi", "zip" } + : runtime.StartsWith("osx-", StringComparison.Ordinal) ? ["dmg", "zip"] : ["zip"]; + foreach (var format in formats) + { + var name = $"Iseberg-{latest.Numeric}-{runtime}.{format}"; + if (release.Assets.Any(asset => asset.Name == name && asset.State == "uploaded")) + return new(latest.Numeric.ToString(), releaseUri, + new Uri($"{RepositoryUrl}/releases/download/{Uri.EscapeDataString(release.Tag)}/{Uri.EscapeDataString(name)}"), true); + } + return new(latest.Numeric.ToString(), releaseUri, releaseUri, false); + } + + private static (Version Numeric, bool IsPrerelease) ParseVersion(string? value) + { + var match = Regex.Match(value ?? "", + @"^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$", + RegexOptions.CultureInvariant); + if (!match.Success || !Version.TryParse($"{match.Groups[1]}.{match.Groups[2]}.{match.Groups[3]}", out var version)) + throw new InvalidDataException($"Invalid release version: '{value}'. Expected a semantic version such as 1.2.3."); + return (version, match.Groups[4].Success); + } + + private sealed class GitHubRelease + { + [JsonPropertyName("tag_name")] public string Tag { get; init; } = ""; + [JsonPropertyName("draft")] public bool Draft { get; init; } + [JsonPropertyName("prerelease")] public bool Prerelease { get; init; } + [JsonPropertyName("assets")] public List Assets { get; init; } = []; + } + + private sealed class GitHubAsset + { + [JsonPropertyName("name")] public string Name { get; init; } = ""; + [JsonPropertyName("state")] public string State { get; init; } = ""; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptEncoding.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptEncoding.cs new file mode 100644 index 0000000..669f68a --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptEncoding.cs @@ -0,0 +1,49 @@ +using System.Text; +using System.Text.Json.Serialization; + +namespace Iseberg.Core; + +public sealed record ScriptEncoding(int CodePage, bool EmitBom, [property: JsonIgnore] string DisplayName) +{ + [JsonConstructor] + public ScriptEncoding(int codePage, bool emitBom) + : this(codePage, emitBom, Choices.FirstOrDefault(choice => + choice.CodePage == codePage && choice.EmitBom == emitBom)?.DisplayName ?? $"Code page {codePage}") { } + + public static IReadOnlyList Choices { get; } = Array.AsReadOnly( + [ + new(65001, false, "UTF-8"), + new(65001, true, "UTF-8 BOM"), + new(1200, true, "UTF-16 LE"), + new(1200, false, "UTF-16 LE (no BOM)"), + new(1201, true, "UTF-16 BE"), + new(1201, false, "UTF-16 BE (no BOM)"), + new(12000, true, "UTF-32 LE"), + new(12000, false, "UTF-32 LE (no BOM)"), + new(12001, true, "UTF-32 BE"), + new(12001, false, "UTF-32 BE (no BOM)"), + new(1252, false, "Windows-1252"), + new(28591, false, "ISO-8859-1"), + new(20127, false, "ASCII"), + new(437, false, "OEM 437"), + new(850, false, "OEM 850") + ]); + + public Encoding CreateEncoding() + { + if (CodePage <= 0) throw new ArgumentOutOfRangeException(nameof(CodePage), "Choose an explicit code page."); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return CodePage switch + { + 65001 => new UTF8Encoding(EmitBom, true), + 1200 => new UnicodeEncoding(false, EmitBom, true), + 1201 => new UnicodeEncoding(true, EmitBom, true), + 12000 => new UTF32Encoding(false, EmitBom, true), + 12001 => new UTF32Encoding(true, EmitBom, true), + _ when EmitBom => throw new ArgumentException("This encoding does not support a Unicode BOM.", nameof(EmitBom)), + _ => Encoding.GetEncoding(CodePage, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback) + }; + } + + internal bool Matches(ScriptEncoding other) => CodePage == other.CodePage && EmitBom == other.EmitBom; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptFile.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptFile.cs new file mode 100644 index 0000000..5ad3f3b --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptFile.cs @@ -0,0 +1,232 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace Iseberg.Core; + +public sealed class ScriptFile : INotifyPropertyChanged +{ + private string text = ""; + private string savedText = ""; + private string? path; + private ScriptEncoding encodingChoice = ScriptEncoding.Choices[0]; + private ScriptEncoding savedEncodingChoice = ScriptEncoding.Choices[0]; + private bool detachedDraft; + public event PropertyChangedEventHandler? PropertyChanged; + public string UntitledName { get; } + public SortedSet Breakpoints { get; } = []; + public int CaretOffset { get; set; } + public string? Path => path; + public Guid? RemoteRunspaceId { get; private set; } + public string? RemoteComputerName { get; private set; } + public bool IsRemote => RemoteRunspaceId is not null; + public string Name => path is null ? UntitledName : path.Split('\\', '/')[^1]; + public string Title => Name + (IsRemote ? $" [{RemoteComputerName}]" : "") + (IsDirty ? "*" : ""); + public bool IsDirty => detachedDraft || text != savedText || !encodingChoice.Matches(savedEncodingChoice); + public ScriptEncoding EncodingChoice => encodingChoice; + public ScriptEncoding SavedEncodingChoice => savedEncodingChoice; + public string EncodingName => encodingChoice.DisplayName; + public string? SavedVersion { get; private set; } + public string Text + { + get => text; + set + { + if (text == value) return; + text = value; + Changed(); + Changed(nameof(IsDirty)); + Changed(nameof(Title)); + } + } + + public ScriptFile(string untitledName) => UntitledName = untitledName; + + public void SetEncoding(ScriptEncoding choice) + { + ArgumentNullException.ThrowIfNull(choice); + choice.CreateEncoding().GetByteCount(text); + if (encodingChoice.Matches(choice)) return; + encodingChoice = choice; + Changed(nameof(EncodingChoice)); + Changed(nameof(EncodingName)); + Changed(nameof(IsDirty)); + Changed(nameof(Title)); + } + + public static Task OpenAsync(string filePath) => OpenAsync(filePath, null); + + public static async Task OpenAsync(string filePath, ScriptEncoding? choice = null) => + FromBytes(filePath, await File.ReadAllBytesAsync(filePath), choice); + + public static ScriptFile FromBytes(string filePath, byte[] bytes, ScriptEncoding? choice = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + var file = DecodeBytes(System.IO.Path.GetFileName(filePath), bytes, choice); + file.path = System.IO.Path.GetFullPath(filePath); + return file; + } + + public static ScriptFile CreateUntitled(string name, ScriptEncoding? encoding = null) + { + encoding ??= ScriptEncoding.Choices[0]; + _ = encoding.CreateEncoding(); + return new ScriptFile(name) { encodingChoice = encoding, savedEncodingChoice = encoding }; + } + + public static ScriptFile FromRecovery(string name, string text, ScriptEncoding? encoding = null) + { + ArgumentNullException.ThrowIfNull(text); + var file = new ScriptFile(name) { detachedDraft = true }; + if (encoding is not null) file.SetEncoding(encoding); + file.Text = text; + return file; + } + + public static async Task ReadVersionAsync(string filePath) + { + await using var stream = OpenVersionStream(filePath); + return stream is null ? null : Convert.ToHexString(await SHA256.HashDataAsync(stream)); + } + + public async Task HasExternalChangesAsync() + { + if (IsRemote) throw new InvalidOperationException("Use the owning remote session to check this document."); + return path is not null && !VersionsMatch(SavedVersion, await ReadVersionAsync(path)); + } + + public Task SaveAsync(string filePath) + { + var fullPath = System.IO.Path.GetFullPath(filePath); + var samePath = path is not null && string.Equals(path, fullPath, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + return SaveAsync(fullPath, samePath ? SavedVersion : null); + } + + // A null expected version requires absence; a hash authorizes replacement of only those bytes. + public async Task SaveAsync(string filePath, string? expectedVersion) + { + if (IsRemote) throw new InvalidOperationException("Use the owning remote session to save this document."); + var fullPath = System.IO.Path.GetFullPath(filePath); + var snapshot = Text; + var snapshotEncoding = encodingChoice; + var bytes = Encode(snapshot, snapshotEncoding); + var temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllBytesAsync(temporary, bytes); + // Deny byte writers while checking/replacing, but allow our atomic rename on Windows. + await using var current = OpenVersionStream(fullPath); + var actualVersion = current is null ? null : Convert.ToHexString(await SHA256.HashDataAsync(current)); + if (!VersionsMatch(expectedVersion, actualVersion)) + throw new FileConflictException(fullPath, expectedVersion, actualVersion); + if (current is not null) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(temporary, File.GetUnixFileMode(fullPath)); + File.Replace(temporary, fullPath, null); + } + else + File.Move(temporary, fullPath); + path = fullPath; + savedText = snapshot; + savedEncodingChoice = snapshotEncoding; + detachedDraft = false; + SavedVersion = GetVersion(bytes); + Changed(nameof(Path)); + Changed(nameof(Name)); + Changed(nameof(Title)); + Changed(nameof(IsDirty)); + Changed(nameof(SavedVersion)); + Changed(nameof(SavedEncodingChoice)); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public static ScriptFile FromRemoteBytes(string path, byte[] bytes, Guid runspaceId, string computerName) => + FromRemoteBytes(path, bytes, runspaceId, computerName, null); + + public static ScriptFile FromRemoteBytes(string path, byte[] bytes, Guid runspaceId, string computerName, + ScriptEncoding? choice = null) + { + var file = DecodeBytes(path.Split('\\', '/')[^1], bytes, choice); + file.path = path; + file.RemoteRunspaceId = runspaceId; + file.RemoteComputerName = computerName; + return file; + } + + internal static byte[] Encode(string snapshot, ScriptEncoding choice) + { + var encoding = choice.CreateEncoding(); + return encoding.GetPreamble().Concat(encoding.GetBytes(snapshot)).ToArray(); + } + + internal void MarkRemoteSaved(string savedPath, string snapshot, ScriptEncoding snapshotEncoding, + string version, Guid runspaceId, string computerName) + { + path = savedPath; + savedText = snapshot; + savedEncodingChoice = snapshotEncoding; + detachedDraft = false; + SavedVersion = version; + RemoteRunspaceId = runspaceId; + RemoteComputerName = computerName; + Changed(nameof(Path)); Changed(nameof(Name)); Changed(nameof(Title)); Changed(nameof(IsDirty)); + Changed(nameof(IsRemote)); Changed(nameof(RemoteRunspaceId)); + Changed(nameof(SavedVersion)); + Changed(nameof(SavedEncodingChoice)); + } + + internal static string GetVersion(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); + + internal static bool VersionsMatch(string? expected, string? actual) => + string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase); + + private static FileStream? OpenVersionStream(string filePath) + { + try { return new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, + 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + } + + private static ScriptFile DecodeBytes(string name, byte[] bytes, ScriptEncoding? choice) + { + ArgumentNullException.ThrowIfNull(bytes); + var (detected, skip) = DetectEncoding(bytes); + choice ??= detected; + if (choice.CodePage != detected.CodePage) skip = 0; + var file = new ScriptFile(name) + { + encodingChoice = choice, + savedEncodingChoice = choice, + SavedVersion = GetVersion(bytes) + }; + file.text = file.savedText = choice.CreateEncoding().GetString(bytes, skip, bytes.Length - skip); + return file; + } + + private static (ScriptEncoding Choice, int Skip) DetectEncoding(byte[] bytes) + { + if (bytes.AsSpan().StartsWith(new byte[] { 0xff, 0xfe, 0x00, 0x00 })) return (new(12000, true, "UTF-32 LE"), 4); + if (bytes.AsSpan().StartsWith(new byte[] { 0x00, 0x00, 0xfe, 0xff })) return (new(12001, true, "UTF-32 BE"), 4); + if (bytes.AsSpan().StartsWith(new byte[] { 0xef, 0xbb, 0xbf })) return (new(65001, true, "UTF-8 BOM"), 3); + if (bytes.AsSpan().StartsWith(new byte[] { 0xff, 0xfe })) return (new(1200, true, "UTF-16 LE"), 2); + if (bytes.AsSpan().StartsWith(new byte[] { 0xfe, 0xff })) return (new(1201, true, "UTF-16 BE"), 2); + return (ScriptEncoding.Choices[0], 0); + } + + private void Changed([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new(name)); +} + +public sealed class FileConflictException(string filePath, string? expectedVersion, string? actualVersion) + : IOException($"'{filePath}' changed, was deleted, or already exists. Check its contents before saving again.") +{ + public string FilePath { get; } = filePath; + public string? ExpectedVersion { get; } = expectedVersion; + public string? ActualVersion { get; } = actualVersion; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptPrint.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptPrint.cs new file mode 100644 index 0000000..7c6d726 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptPrint.cs @@ -0,0 +1,42 @@ +using System.Net; + +namespace Iseberg.Core; + +public static class ScriptPrint +{ + public static string CreateHtml(string title, string text, string printLabel, string hint) + { + string Escape(string value) => WebUtility.HtmlEncode(value); + return $$""" + + + + + + {{Escape(title)}} + + + + +

{{Escape(title)}}

+
{{Escape(text)}}
+ + + """; + } + + public static async Task WriteAsync(string path, string html) + { + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await using var stream = new FileStream(path, options); + await using var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(false)); + await writer.WriteAsync(html); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptRecovery.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptRecovery.cs new file mode 100644 index 0000000..bff4f96 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptRecovery.cs @@ -0,0 +1,74 @@ +using System.Text.Json; + +namespace Iseberg.Core; + +public sealed record RecoveredScript(Guid Id, string Name, string? Path, string Text, int OwnerProcessId = 0, + DateTime OwnerStartedUtc = default, ScriptEncoding? Encoding = null, string? SessionName = null); + +public sealed class ScriptRecovery(string? directory = null) +{ + private readonly string directory = directory ?? System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Iseberg", "Recovery"); + private readonly int ownerProcessId = Environment.ProcessId; + private readonly DateTime ownerStartedUtc = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime(); + + public async Task SaveAsync(Guid id, ScriptFile file, string? sessionName = null, bool released = false) + { + if (!file.IsDirty) { Remove(id); return; } + if (OperatingSystem.IsWindows()) Directory.CreateDirectory(directory); + else + { + Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + var path = SnapshotPath(id); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await using (var stream = new FileStream(temporary, options)) + await JsonSerializer.SerializeAsync(stream, new RecoveredScript(id, file.Name, file.Path, file.Text, + released ? 0 : ownerProcessId, ownerStartedUtc, file.EncodingChoice, sessionName)); + File.Move(temporary, path, overwrite: true); + } + finally { File.Delete(temporary); } + } + + public async Task> ReadAsync() + { + if (!Directory.Exists(directory)) return []; + var scripts = new List(); + foreach (var path in Directory.EnumerateFiles(directory, "*.json").Order()) + { + var script = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)) + ?? throw new InvalidDataException($"Empty recovery file: {path}"); + if (script.Id == Guid.Empty || string.IsNullOrWhiteSpace(script.Name) || script.Text is null) + throw new InvalidDataException($"Invalid recovery file: {path}"); + if (script.Encoding is not null) _ = script.Encoding.CreateEncoding(); + if (!OwnerIsRunning(script)) scripts.Add(script); + } + return scripts; + } + + public void Remove(Guid id) + { + try { File.Delete(SnapshotPath(id)); } + catch (DirectoryNotFoundException) + { + // No snapshot remains when autosave never created the directory or it was removed. + } + } + private static bool OwnerIsRunning(RecoveredScript script) + { + if (script.OwnerProcessId <= 0) return false; + try + { + using var process = System.Diagnostics.Process.GetProcessById(script.OwnerProcessId); + return !process.HasExited && process.StartTime.ToUniversalTime() == script.OwnerStartedUtc; + } + catch (ArgumentException) { return false; } + catch (InvalidOperationException) { return false; } + } + private string SnapshotPath(Guid id) => System.IO.Path.Combine(directory, id.ToString("N") + ".json"); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/SessionEvents.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/SessionEvents.cs new file mode 100644 index 0000000..f79cedc --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/SessionEvents.cs @@ -0,0 +1,46 @@ +using System.Management.Automation; + +namespace Iseberg.Core; + +public enum OutputKind { Output, Error, Warning, Verbose, Debug, Information, Command } +public enum SessionState { Starting, Ready, Running, Debugging, NestedPrompt, Disposed } + +public sealed record OutputStyle(string? Foreground = null, string? Background = null, bool Bold = false, bool Underline = false, bool Inverse = false); +public sealed record OutputEntry(string Text, OutputKind Kind = OutputKind.Output, int CodeStart = 0, OutputStyle? Style = null); +public sealed record DebugLocation(string? ScriptPath, int Line, int Column, string Message); +public sealed record ProgressUpdate(string Activity, string Status, int Percent, bool Completed, + long SourceId = 0, int ActivityId = 0, int ParentActivityId = -1, int SecondsRemaining = -1, string? CurrentOperation = null); +public sealed record CompletionSet(int Start, int Length, IReadOnlyList Matches); +public sealed record CommandDescription(string Name, string Module, string Kind, string Definition); + +public sealed class ShowCommandRequest +{ + public CommandFormDescription? Command { get; init; } + public IReadOnlyList Commands { get; init; } = []; + public string HelpText { get; init; } = ""; + public CommandHelpDocument? HelpDocument { get; init; } + public Uri? HelpUri { get; init; } + public bool PassThru { get; init; } + public double Width { get; init; } = 360; + public double Height { get; init; } = 410; + public TaskCompletionSource Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); +} + +public sealed class InputRequest(string caption, string message, bool secret = false) +{ + public string Caption { get; } = caption; + public string Message { get; } = message; + public bool Secret { get; } = secret; + public IReadOnlyList Choices { get; init; } = []; + public IReadOnlyList DefaultChoices { get; init; } = []; + public bool MultipleChoice { get; init; } + public TaskCompletionSource Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); +} + +public sealed record PromptChoice(string Label, string Help); + +public sealed class CommandErrorRequest(string message) +{ + public string Message { get; } = message; + public TaskCompletionSource Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ShowCommandCommand.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ShowCommandCommand.cs new file mode 100644 index 0000000..a3d015e --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ShowCommandCommand.cs @@ -0,0 +1,87 @@ +using System.Management.Automation; + +namespace Iseberg.Core; + +[Cmdlet(VerbsCommon.Show, "IsebergCommand")] +[OutputType(typeof(string))] +public sealed class ShowCommandCommand : PSCmdlet +{ + [Parameter(Position = 0)] + [ValidateNotNullOrEmpty] + public string? Name { get; set; } + + [Parameter] + public SwitchParameter PassThru { get; set; } + + [Parameter] + public SwitchParameter NoCommonParameter { get; set; } + + [Parameter] + public SwitchParameter ErrorPopup { get; set; } + + [Parameter] + [ValidateRange(300, int.MaxValue)] + public int Width { get; set; } = 360; + + [Parameter] + [ValidateRange(300, int.MaxValue)] + public int Height { get; set; } = 410; + + protected override void EndProcessing() + { + if (Host.PrivateData?.BaseObject is not WorkbenchHostServices host) + throw new PSNotSupportedException("This Show-Command implementation requires the Iseberg host."); + var name = Name; + if (string.IsNullOrEmpty(name)) + { + var commands = InvokeCommand.InvokeScript(false, ScriptBlock.Create("Get-Command"), null) + .Select(entry => entry.BaseObject).OfType(); + name = host.ShowCommand(new() { Commands = PowerShellSession.DescribeCommands(commands) }); + if (name is null) return; + } + var command = InvokeCommand.GetCommand(name, CommandTypes.All) + ?? throw new CommandNotFoundException($"Command '{name}' was not found in this PowerShell tab."); + var description = CommandForm.Describe(command); + if (NoCommonParameter) + description = description with + { + ParameterSets = description.ParameterSets.Select(set => set with + { + Parameters = set.Parameters.Where(parameter => !parameter.IsCommon).ToArray() + }).ToArray() + }; + // Nested invocations stay on the pipeline thread; desktop queries would wait on its gate. + var help = InvokeCommand.InvokeScript(false, + ScriptBlock.Create("Get-Help -Name $args[0] -Full"), null, name); + var helpText = InvokeCommand.InvokeScript(false, + ScriptBlock.Create("$args[0] | Out-String -Width 100"), null, new object[] { help }); + var helpDocument = CommandHelpDocument.FromHelp(description.Name, help); + var script = host.ShowCommand(new() + { + Command = description, PassThru = PassThru, Width = Width, Height = Height, + HelpText = string.Join(Environment.NewLine, helpText.Select(entry => entry.ToString())), + HelpDocument = helpDocument, + HelpUri = PowerShellSession.FindHelpUri(help) + }); + if (script is null) return; + if (PassThru) + WriteObject(script); + else if (ErrorPopup) + { + using var shell = PowerShell.Create(RunspaceMode.CurrentRunspace); + shell.AddScript(script, useLocalScope: false); + var output = new List(); + ErrorRecord? terminating = null; + try { shell.Invoke(null, output); } + catch (RuntimeException exception) when (exception is not PipelineStoppedException) + { terminating = exception.ErrorRecord; } + foreach (var entry in output) WriteObject(entry); + var errors = shell.Streams.Error.ToList(); + if (terminating is not null && !errors.Contains(terminating)) errors.Add(terminating); + if (errors.Count > 0) host.ShowCommandError(string.Join(Environment.NewLine, errors.Select(error => error.ToString()))); + } + else + foreach (var entry in InvokeCommand.InvokeScript(false, ScriptBlock.Create(script), null)) + WriteObject(entry); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/SnippetCatalog.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/SnippetCatalog.cs new file mode 100644 index 0000000..0935dc7 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/SnippetCatalog.cs @@ -0,0 +1,284 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace Iseberg.Core; + +public sealed record PowerShellSnippet(string Title, string Description, string Author, string Code, + int CaretOffset = -1, bool Indent = true, bool IsBuiltIn = false, string Compatibility = "") +{ + public string DisplayTitle => Title; + public string Text => Code; + public override string ToString() => Title; + + public (string Text, int Caret) Expand(string indentation, string newLine) + { + var caret = CaretOffset < 0 ? Code.Length : CaretOffset; + ArgumentOutOfRangeException.ThrowIfGreaterThan(caret, Code.Length); + var result = new StringBuilder(); + var expandedCaret = 0; + var separator = newLine + (Indent ? indentation : ""); + for (var index = 0; index < Code.Length; index++) + { + if (index == caret) expandedCaret = result.Length; + if (Code[index] is '\r' or '\n') + { + result.Append(separator); + if (Code[index] == '\r' && index + 1 < Code.Length && Code[index + 1] == '\n') + { + if (caret == index + 1) expandedCaret = result.Length; + index++; + } + } + else result.Append(Code[index]); + } + if (caret == Code.Length) expandedCaret = result.Length; + return (result.ToString(), expandedCaret); + } +} + +public sealed record SnippetLoadResult(IReadOnlyList Snippets, IReadOnlyList Errors); + +public static class SnippetCatalog +{ + private static readonly XNamespace Namespace = "http://schemas.microsoft.com/PowerShell/Snippets"; + private const string Legacy = "Requires Windows PowerShell 5.1; workflow and classic DSC syntax are not supported by PowerShell 7."; + public static string UserDirectory => Path.Combine(Path.GetDirectoryName(UserSettings.SettingsPath)!, "Snippets"); + public static IReadOnlyList BuiltIns { get; } = Array.AsReadOnly(new[] + { + Template("if", "Conditional execution.", "if ($condition) {\n \n}"), + Template("if-else", "Conditional execution with an alternative.", "if ($condition) {\n \n}\nelse {\n \n}"), + Template("for", "Counter-controlled loop.", "for ($index = 0; $index -lt $count; $index++) {\n \n}"), + Template("foreach", "Iterate over a collection.", "foreach ($item in $collection) {\n \n}"), + Template("function", "Function with two parameters.", "function Verb-Noun {\n param($First, $Second)\n\n \n}"), + Template("Cmdlet (advanced function)", "Pipeline-aware advanced function.", """ + function Verb-Noun { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [object] $InputObject + ) + process { + + } + } + """), + Template("Cmdlet (advanced function) - complete", "Advanced function with help, validation, lifecycle blocks and ShouldProcess.", """ + function Verb-Noun { + <# + .SYNOPSIS + Describe the command. + .DESCRIPTION + Describe its behavior and requirements. + .PARAMETER InputObject + The value to process. + .EXAMPLE + 'example' | Verb-Noun -WhatIf + .INPUTS + System.String + .OUTPUTS + System.Object + .NOTES + Add author and version information. + .LINK + Get-Help + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([object])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [string[]] $InputObject + ) + begin { + } + process { + foreach ($value in $InputObject) { + if ($PSCmdlet.ShouldProcess($value, 'Process')) { + + } + } + } + end { + } + } + """), + Template("switch", "Dispatch by value.", "switch ($value) {\n 'First' { ; break }\n 'Second' { ; break }\n default { }\n}"), + Template("while", "Test before each iteration.", "while ($condition) {\n \n}"), + Template("do-while", "Execute once, then continue while true.", "do {\n \n} while ($condition)"), + Template("do-until", "Execute once, then continue until true.", "do {\n \n} until ($condition)"), + Template("try-catch-finally", "Handle terminating errors and always clean up.", "try {\n \n}\ncatch {\n Write-Error -ErrorRecord $_\n}\nfinally {\n \n}"), + Template("try-finally", "Always clean up after an operation.", "try {\n \n}\nfinally {\n \n}"), + Template("Comment block", "Multiline comment.", "<#\n\n#>"), + Template("Workflow InlineScript", "Run PowerShell inside a workflow.", "InlineScript {\n \n}", Legacy), + Template("Workflow Parallel", "Run workflow activities in parallel.", "parallel {\n \n}", Legacy), + Template("Workflow Sequence", "Run workflow activities in sequence.", "sequence {\n \n}", Legacy), + Template("Workflow ForEachParallel", "Process workflow items in parallel.", "foreach -parallel ($item in $collection) {\n \n}", Legacy), + Template("Workflow (simple)", "Windows PowerShell workflow.", "workflow Invoke-Workflow {\n param([string] $Name)\n\n \n}", Legacy), + Template("Workflow (advanced)", "Parameterized workflow with parallel and inline activities.", """ + workflow Invoke-Workflow { + param( + [Parameter(Mandatory)] + [string[]] $ComputerName + ) + foreach -parallel ($computer in $ComputerName) { + sequence { + InlineScript { + $target = $using:computer + + } + } + } + } + """, Legacy), + Template("DSC Resource Provider (simple)", "Script-based DSC resource functions.", """ + function Get-TargetResource { + [CmdletBinding()] + [OutputType([hashtable])] + param([Parameter(Mandatory)][string] $Name) + + return @{ Name = $Name } + } + function Test-TargetResource { + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)][string] $Name) + return $false + } + function Set-TargetResource { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $Name) + } + """, Legacy), + Template("DSC Configuration (simple)", "Classic DSC node configuration.", "configuration Workstation {\n param([string[]] $ComputerName = 'localhost')\n Import-DscResource -ModuleName PSDesiredStateConfiguration\n Node $ComputerName {\n \n }\n}", Legacy), + Template("DSC Configuration (using ConfigurationData)", "Use AllNodes in a classic DSC configuration.", "configuration Workstation {\n Import-DscResource -ModuleName PSDesiredStateConfiguration\n Node $AllNodes.NodeName {\n \n }\n}\nWorkstation -ConfigurationData $ConfigurationData", Legacy), + Template("DSC ConfigurationData", "Separate node data from configuration.", "$ConfigurationData = @{\n AllNodes = @(\n @{\n NodeName = 'localhost'\n \n }\n )\n}", Legacy), + Template("DSC Resource with Class (simple)", "Class-based DSC resource contract.", """ + [DscResource()] + class ManagedItem { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [string] $Value + + [void] Set() { + + } + [bool] Test() { + return $false + } + [ManagedItem] Get() { + return $this + } + } + """, Legacy), + Template("class (simple)", "PowerShell class with a constructor and method.", "class Item {\n [string] $Name\n\n Item([string] $name) {\n $this.Name = $name\n }\n\n [string] Describe() {\n \n return $this.Name\n }\n}") + }); + + private static PowerShellSnippet Template(string title, string description, string code, string compatibility = "") + { + var offset = code.IndexOf("", StringComparison.Ordinal); + return new(title, description, "Iseberg", code.Replace("", "", StringComparison.Ordinal), offset, IsBuiltIn: true, Compatibility: compatibility); + } + + public static IReadOnlyList Parse(string xml) + { + using var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 2_000_000 + }); + var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace); + if (document.Root?.Name != Namespace + "Snippets") throw new InvalidDataException("Expected a PowerShell Snippets document."); + var snippets = new List(); + foreach (var element in document.Root.Elements(Namespace + "Snippet")) + { + if (!Version.TryParse(element.Attribute("Version")?.Value, out var version) || version > new Version(1, 0, 0)) + throw new InvalidDataException("Only snippet schema version 1.0.0 is supported."); + var header = element.Element(Namespace + "Header") ?? throw new InvalidDataException("A snippet is missing its Header."); + string Required(string name) => header.Element(Namespace + name)?.Value ?? + throw new InvalidDataException($"A snippet is missing {name}."); + var title = Required("Title"); + if (string.IsNullOrWhiteSpace(title)) throw new InvalidDataException("A snippet title cannot be empty."); + var script = element.Element(Namespace + "Code")?.Elements(Namespace + "Script") + .FirstOrDefault(s => string.Equals(s.Attribute("Language")?.Value, "PowerShell", StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidDataException($"Snippet '{title}' is missing its PowerShell Script."); + var offset = -1; + if (script.Attribute("CaretOffset") is { } caret && + (!int.TryParse(caret.Value, out offset) || offset < 0 || offset > script.Value.Length)) + throw new InvalidDataException($"Snippet '{title}' has an invalid CaretOffset."); + var indent = true; + if (script.Attribute("Indent") is { } indentation && !bool.TryParse(indentation.Value, out indent)) + throw new InvalidDataException($"Snippet '{title}' has an invalid Indent value."); + snippets.Add(new(title, Required("Description"), Required("Author"), script.Value, offset, indent)); + } + if (snippets.Count == 0) throw new InvalidDataException("The file contains no snippets."); + return snippets; + } + + public static string Serialize(IEnumerable snippets) + { + var entries = snippets.Select(s => + { + if (string.IsNullOrWhiteSpace(s.Title) || s.CaretOffset < -1 || s.CaretOffset > s.Code.Length) + throw new InvalidDataException("Invalid snippet title or caret offset."); + var script = new XElement(Namespace + "Script", new XAttribute("Language", "PowerShell"), + new XAttribute("Indent", s.Indent), s.Code); + if (s.CaretOffset >= 0) script.Add(new XAttribute("CaretOffset", s.CaretOffset)); + return new XElement(Namespace + "Snippet", new XAttribute("Version", "1.0.0"), + new XElement(Namespace + "Header", new XElement(Namespace + "Title", s.Title), + new XElement(Namespace + "Description", s.Description), new XElement(Namespace + "Author", s.Author)), + new XElement(Namespace + "Code", script)); + }); + var xml = new StringBuilder(); + using (var writer = XmlWriter.Create(xml, new XmlWriterSettings + { + Indent = true, OmitXmlDeclaration = true, NewLineHandling = NewLineHandling.Entitize + })) + new XDocument(new XElement(Namespace + "Snippets", entries)).Save(writer); + return xml.ToString(); + } + + public static async Task SaveAsync(string path, IEnumerable snippets, bool overwrite = true) + { + var xml = Serialize(snippets); + var fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + var temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllTextAsync(temporary, xml, new UTF8Encoding(false)); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temporary, UnixFileMode.UserRead | UnixFileMode.UserWrite); + File.Move(temporary, fullPath, overwrite); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + + public static IReadOnlyList DefaultDirectories => OperatingSystem.IsWindows() + ? new[] { UserDirectory, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "WindowsPowerShell", "Snippets") } + : new[] { UserDirectory }; + + public static async Task LoadAsync(IEnumerable? directories = null) + { + directories ??= DefaultDirectories; + var snippets = new List(); + var errors = new List(); + foreach (var directory in directories) + { + if (!Directory.Exists(directory)) continue; + try + { + foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Where(p => p.EndsWith(".snippets.ps1xml", StringComparison.OrdinalIgnoreCase)).Order(StringComparer.Ordinal)) + { + try { snippets.AddRange(Parse(await File.ReadAllTextAsync(path))); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or XmlException or InvalidDataException) + { errors.Add(path + ": " + exception.Message); } + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { errors.Add(directory + ": " + exception.Message); } + } + return new(snippets.Distinct().ToArray(), errors); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/StartTerminalCommand.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/StartTerminalCommand.cs new file mode 100644 index 0000000..3564ad4 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/StartTerminalCommand.cs @@ -0,0 +1,50 @@ +using System.Management.Automation; + +namespace Iseberg.Core; + +[Cmdlet(VerbsLifecycle.Start, "IsebergTerminal")] +[OutputType(typeof(int))] +public sealed class StartTerminalCommand : PSCmdlet +{ + private readonly CancellationTokenSource cancellation = new(); + private readonly object sync = new(); + private bool finished; + + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNullOrEmpty] + public string FilePath { get; set; } = ""; + + [Parameter(Position = 1)] + public string[] ArgumentList { get; set; } = []; + + [Parameter] + public string? WorkingDirectory { get; set; } + + protected override void EndProcessing() + { + try + { + if (Host.PrivateData?.BaseObject is not WorkbenchHostServices services || services.IsRunspacePushed()) + throw new PSNotSupportedException("Start-IsebergTerminal requires a local Iseberg runspace."); + var executable = InvokeCommand.GetCommand(FilePath, CommandTypes.Application) as ApplicationInfo + ?? throw new CommandNotFoundException($"Native application '{FilePath}' was not found."); + var directory = WorkingDirectory is null ? SessionState.Path.CurrentFileSystemLocation.ProviderPath : + SessionState.Path.GetUnresolvedProviderPathFromPSPath(WorkingDirectory); + WriteWarning("Input/output will use a separate system terminal. Stop terminates the application and its child processes."); + var exitCode = TerminalApplication.RunAsync(executable.Path, ArgumentList, directory, cancellation.Token) + .GetAwaiter().GetResult(); + SessionState.PSVariable.Set("LASTEXITCODE", exitCode); + WriteObject(exitCode); + } + catch (OperationCanceledException) { throw new PipelineStoppedException(); } + finally + { + lock (sync) { finished = true; cancellation.Dispose(); } + } + } + + protected override void StopProcessing() + { + lock (sync) if (!finished) cancellation.Cancel(); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/TerminalApplication.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/TerminalApplication.cs new file mode 100644 index 0000000..e18d2ec --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/TerminalApplication.cs @@ -0,0 +1,205 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace Iseberg.Core; + +public static class TerminalApplication +{ + public static async Task RunAsync(string executable, IReadOnlyList arguments, string workingDirectory, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executable); + if (!Path.IsPathFullyQualified(executable) || !File.Exists(executable)) + throw new FileNotFoundException("The terminal application must resolve to an existing executable.", executable); + if (!Directory.Exists(workingDirectory)) throw new DirectoryNotFoundException(workingDirectory); + if (arguments.Any(argument => argument is null || argument.Contains('\0'))) + throw new ArgumentException("Terminal arguments cannot be null or contain NUL.", nameof(arguments)); + cancellationToken.ThrowIfCancellationRequested(); + if (OperatingSystem.IsWindows()) + { + using var process = StartWindows(executable, arguments, workingDirectory); + try { await process.WaitForExitAsync(cancellationToken); } + catch (OperationCanceledException) + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + throw; + } + return process.ExitCode; + } + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + throw new PlatformNotSupportedException("System terminal integration supports Windows, Linux (xterm) and macOS (Terminal.app)."); + return await RunUnixAsync(executable, arguments, workingDirectory, cancellationToken); + } + + public static string QuoteWindowsArgument(string argument) + { + var result = new StringBuilder("\""); + var slashes = 0; + foreach (var character in argument) + { + if (character == '\\') { slashes++; continue; } + result.Append('\\', character == '"' ? slashes * 2 + 1 : slashes); + result.Append(character); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + public static string QuoteUnixArgument(string argument) => "'" + argument.Replace("'", "'\"'\"'") + "'"; + + private static Process StartWindows(string executable, IReadOnlyList arguments, string directory) + { + var startup = new StartupInfo { Size = Marshal.SizeOf() }; + var commandLine = new StringBuilder(string.Join(" ", new[] { executable }.Concat(arguments).Select(QuoteWindowsArgument))); + if (!CreateProcess(executable, commandLine, IntPtr.Zero, IntPtr.Zero, false, 0x00000010, + IntPtr.Zero, directory, ref startup, out var info)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not open the application in a new Windows console."); + try + { + var process = Process.GetProcessById(info.ProcessId); + _ = process.SafeHandle; + return process; + } + finally { CloseHandle(info.Thread); CloseHandle(info.Process); } + } + + private static async Task RunUnixAsync(string executable, IReadOnlyList arguments, string directory, + CancellationToken cancellationToken) + { + if (OperatingSystem.IsWindows()) throw new PlatformNotSupportedException(); + var folder = Directory.CreateTempSubdirectory("iseberg-terminal-"); + var script = Path.Combine(folder.FullName, "run.command"); + var pidPath = Path.Combine(folder.FullName, "pid"); + var exitPath = Path.Combine(folder.FullName, "exit"); + var goPath = Path.Combine(folder.FullName, "go"); + Process? child = null; + Process? terminal = null; + try + { + var command = string.Join(" ", new[] { executable }.Concat(arguments).Select(QuoteUnixArgument)); + await File.WriteAllTextAsync(script, $$""" + #!/bin/sh + printf '%s' "$$" > {{QuoteUnixArgument(pidPath + ".tmp")}} + mv {{QuoteUnixArgument(pidPath + ".tmp")}} {{QuoteUnixArgument(pidPath)}} + child='' + finish() { + code=$? + trap - EXIT HUP INT TERM + if [ -n "$child" ]; then kill -TERM "$child" 2>/dev/null; fi + printf '%s' "$code" > {{QuoteUnixArgument(exitPath + ".tmp")}} + mv {{QuoteUnixArgument(exitPath + ".tmp")}} {{QuoteUnixArgument(exitPath)}} + } + trap finish EXIT + trap 'exit 130' HUP INT TERM + while [ ! -f {{QuoteUnixArgument(goPath)}} ]; do + [ -f {{QuoteUnixArgument(script)}} ] || exit 130 + sleep 0.05 + done + cd {{QuoteUnixArgument(directory)}} || exit 125 + {{command}} < /dev/tty > /dev/tty 2>&1 & + child=$! + wait "$child" + code=$? + child='' + exit "$code" + """ + "\n", cancellationToken); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var start = new ProcessStartInfo + { + FileName = OperatingSystem.IsMacOS() ? "/usr/bin/open" : "xterm", + UseShellExecute = false + }; + if (OperatingSystem.IsMacOS()) + { + start.ArgumentList.Add("-a"); + start.ArgumentList.Add("Terminal"); + start.ArgumentList.Add(script); + } + else + { + start.ArgumentList.Add("-e"); + start.ArgumentList.Add("/bin/sh"); + start.ArgumentList.Add(script); + } + try { terminal = Process.Start(start) ?? throw new InvalidOperationException("The system terminal did not start."); } + catch (Win32Exception exception) + { throw new InvalidOperationException("Could not open a system terminal. Linux requires xterm and a graphical display; macOS requires Terminal.app.", exception); } + var deadline = Stopwatch.StartNew(); + while (!File.Exists(exitPath)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (child is null && File.Exists(pidPath)) + { + var pid = await File.ReadAllTextAsync(pidPath, cancellationToken); + if (int.TryParse(pid, out var id)) + { + try + { + child = Process.GetProcessById(id); + cancellationToken.ThrowIfCancellationRequested(); + await File.WriteAllTextAsync(goPath, "", cancellationToken); + } + catch (ArgumentException) when (File.Exists(exitPath)) { } + } + } + if (child?.HasExited == true && !File.Exists(exitPath)) + throw new InvalidOperationException("The terminal was closed before its application reported an exit status."); + if (child is null && (deadline.Elapsed > TimeSpan.FromSeconds(20) || + OperatingSystem.IsLinux() && terminal.HasExited)) + throw new InvalidOperationException("The terminal could not start the application. Check the graphical display and terminal installation."); + await Task.Delay(50, cancellationToken); + } + var status = await File.ReadAllTextAsync(exitPath, cancellationToken); + if (!int.TryParse(status, out var exitCode)) + throw new InvalidOperationException("The terminal returned an invalid exit status."); + return exitCode; + } + finally + { + if (child is not null) + { + if (!child.HasExited) child.Kill(entireProcessTree: true); + await child.WaitForExitAsync(CancellationToken.None); + child.Dispose(); + } + if (terminal is not null) + { + if (OperatingSystem.IsLinux() && !terminal.HasExited) terminal.Kill(entireProcessTree: true); + if (OperatingSystem.IsLinux()) await terminal.WaitForExitAsync(CancellationToken.None); + terminal.Dispose(); + } + foreach (var path in new[] { script, pidPath, exitPath, goPath, pidPath + ".tmp", exitPath + ".tmp" }) File.Delete(path); + folder.Delete(); + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int Size; + public string? Reserved, Desktop, Title; + public int X, Y, XSize, YSize, XCountChars, YCountChars, FillAttribute, Flags; + public short ShowWindow, ReservedSize; + public IntPtr ReservedPointer, StandardInput, StandardOutput, StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInfo + { + public IntPtr Process, Thread; + public int ProcessId, ThreadId; + } + + [DllImport("kernel32.dll", EntryPoint = "CreateProcessW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcess(string applicationName, StringBuilder commandLine, IntPtr processAttributes, + IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint creationFlags, + IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, out ProcessInfo processInformation); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/TextReplacement.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/TextReplacement.cs new file mode 100644 index 0000000..a00acf6 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/TextReplacement.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace Iseberg.Core; + +public sealed record ReplacementOptions(bool MatchCase = false, bool WholeWord = false, bool RegularExpression = false, + bool SearchUp = false, bool WrapAround = true); +public sealed record TextReplacement(int Start, int Length, string Text); + +public static class ReplacementSearch +{ + public static IReadOnlyList Find(string text, string pattern, string replacement, + ReplacementOptions options, int start = 0, int? length = null) + { + ArgumentException.ThrowIfNullOrEmpty(pattern); + var count = length ?? text.Length - start; + ArgumentOutOfRangeException.ThrowIfNegative(start); + ArgumentOutOfRangeException.ThrowIfNegative(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(start + count, text.Length); + var expression = options.RegularExpression ? pattern : Regex.Escape(pattern); + if (options.WholeWord) expression = @"(?().Where(m => m.Index >= start && m.Index + m.Length <= start + count) + .Select(m => new TextReplacement(m.Index, m.Length, options.RegularExpression ? m.Result(replacement) : replacement)).ToArray(); + } + + public static TextReplacement? Next(IReadOnlyList matches, int offset, ReplacementOptions options) + { + if (matches.Count == 0) return null; + if (options.SearchUp) + return matches.LastOrDefault(m => m.Start < offset) ?? (options.WrapAround ? matches[^1] : null); + return matches.FirstOrDefault(m => m.Start >= offset) ?? (options.WrapAround ? matches[0] : null); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ThemeFile.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ThemeFile.cs new file mode 100644 index 0000000..e7426bc --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ThemeFile.cs @@ -0,0 +1,51 @@ +using System.Text.Json; + +namespace Iseberg.Core; + +public sealed class ThemeFile +{ + [System.Text.Json.Serialization.JsonRequired] public int Version { get; set; } = 1; + [System.Text.Json.Serialization.JsonRequired] public EditorTheme Theme { get; set; } = new(); + [System.Text.Json.Serialization.JsonRequired] public string FontFamily { get; set; } = "Lucida Console"; + [System.Text.Json.Serialization.JsonRequired] public double FontSize { get; set; } = 9; + + public static ThemeFile FromSettings(UserSettings settings) => new() + { + Theme = settings.Theme.Copy(), FontFamily = settings.FontFamily, FontSize = settings.FontSize + }; + + public void Validate() + { + if (Version != 1 || Theme is null || string.IsNullOrWhiteSpace(Theme.Name) || + Theme.Colors is null || !Theme.Colors.Keys.Order().SequenceEqual(EditorTheme.DefaultColors().Keys.Order()) || + Theme.Colors.Values.Any(color => !EditorTheme.IsColor(color)) || + string.IsNullOrWhiteSpace(FontFamily) || !double.IsFinite(FontSize) || FontSize is < 6 or > 72) + throw new InvalidDataException("The theme file is invalid or unsupported."); + } + + public static async Task LoadAsync(string path) + { + var json = await File.ReadAllTextAsync(path); + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("Theme", out var content) || content.ValueKind != JsonValueKind.Object || + !content.TryGetProperty("Name", out _) || !content.TryGetProperty("Colors", out _)) + throw new InvalidDataException("The theme file must include a named theme and its colors."); + var theme = JsonSerializer.Deserialize(json) + ?? throw new InvalidDataException("The theme file is empty."); + theme.Validate(); + return theme; + } + + public async Task SaveAsync(string path) + { + Validate(); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + File.Move(temporary, path, overwrite: true); + } + finally { File.Delete(temporary); } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/UserSettings.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/UserSettings.cs new file mode 100644 index 0000000..248a0dc --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/UserSettings.cs @@ -0,0 +1,110 @@ +using System.Text.Json; + +namespace Iseberg.Core; + +public sealed class UserSettings +{ + public double Zoom { get; set; } = 100; + public string Layout { get; set; } = "Top"; + public bool ShowCommands { get; set; } = true; + public bool ShowLineNumbers { get; set; } = true; + public bool WordWrap { get; set; } + public bool LoadProfiles { get; set; } + public bool ShowOutlining { get; set; } = true; + public bool WarnDuplicateFiles { get; set; } = true; + public bool PromptToSaveBeforeRun { get; set; } = true; + public bool ConsoleIntelliSense { get; set; } = true; + public bool ConsoleCompletionOnEnter { get; set; } = true; + public bool ScriptIntelliSense { get; set; } = true; + public bool ScriptCompletionOnEnter { get; set; } = true; + public int IntelliSenseTimeoutSeconds { get; set; } = 3; + public bool UseLocalHelp { get; set; } = true; + public HelpViewSettings HelpView { get; set; } = new(); + public bool ShowToolbar { get; set; } = true; + public bool UseDefaultSnippets { get; set; } = true; + public bool CheckForUpdates { get; set; } = true; + public int AutoSaveMinutes { get; set; } = 2; + public int RecentFileCount { get; set; } = 10; + public string FontFamily { get; set; } = "Lucida Console"; + public double FontSize { get; set; } = 9; + public bool FixedWidthFontsOnly { get; set; } + public EditorTheme Theme { get; set; } = new(); + public string? ThemePreset { get; set; } + public List CustomThemes { get; set; } = []; + public List RecentFiles { get; set; } = []; + public List DebuggerSessions { get; set; } = []; + public WorkbenchGeometry Geometry { get; set; } = new(); + public static string SettingsPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Iseberg", "settings.json"); + + public static async Task LoadAsync(string? path = null) + { + path ??= SettingsPath; + if (!File.Exists(path)) return new(); + var json = await File.ReadAllTextAsync(path); + var settings = JsonSerializer.Deserialize(json) ?? throw new InvalidDataException("The settings file is empty."); + settings.Normalize(); + return settings; + } + + public UserSettings Copy() => JsonSerializer.Deserialize(JsonSerializer.Serialize(this))!; + + public void Normalize() + { + Zoom = double.IsFinite(Zoom) ? Math.Clamp(Zoom, 20, 400) : 100; + FontSize = double.IsFinite(FontSize) ? Math.Clamp(FontSize, 6, 72) : 9; + if (string.IsNullOrWhiteSpace(FontFamily)) FontFamily = "Lucida Console"; + AutoSaveMinutes = Math.Clamp(AutoSaveMinutes, 0, 120); + RecentFileCount = Math.Clamp(RecentFileCount, 0, 100); + IntelliSenseTimeoutSeconds = Math.Clamp(IntelliSenseTimeoutSeconds, 1, 30); + Theme ??= new(); + Theme.Normalize(); + HelpView ??= new(); + HelpView.Normalize(); + Geometry ??= new(); + Geometry.Normalize(); + CustomThemes ??= []; + if (CustomThemes.Any(theme => theme is null)) throw new InvalidDataException("A saved theme is empty."); + foreach (var theme in CustomThemes) theme.Normalize(); + RecentFiles ??= []; + if (RecentFiles.Any(string.IsNullOrWhiteSpace)) throw new InvalidDataException("A recent-file path is empty."); + if (RecentFiles.Count > RecentFileCount) RecentFiles.RemoveRange(RecentFileCount, RecentFiles.Count - RecentFileCount); + if (Layout is not ("Top" or "Right" or "Maximized")) Layout = "Top"; + DebuggerSessions ??= []; + if (DebuggerSessions.Any(session => session is null || string.IsNullOrWhiteSpace(session.Name)) || + DebuggerSessions.DistinctBy(session => session.Name).Count() != DebuggerSessions.Count) + throw new InvalidDataException("Saved debugger sessions must have unique names."); + foreach (var session in DebuggerSessions) + { + if (session.Watches is null || session.Watches.Any(string.IsNullOrWhiteSpace) || session.Breakpoints is null) + throw new InvalidDataException("A saved debugger configuration is invalid."); + foreach (var breakpoint in session.Breakpoints) + { + if (breakpoint is null) throw new InvalidDataException("A saved breakpoint is empty."); + try { breakpoint.Validate(); } + catch (Exception exception) when (exception is ArgumentException or System.Management.Automation.ParseException) + { throw new InvalidDataException("A saved breakpoint is invalid.", exception); } + } + } + } + + public async Task SaveAsync(string? path = null) + { + path ??= SettingsPath; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + File.Move(temporary, path, overwrite: true); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } +} + +public sealed class DebuggerSessionSettings +{ + public string Name { get; set; } = ""; + public List Watches { get; set; } = []; + public List Breakpoints { get; set; } = []; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchHost.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchHost.cs new file mode 100644 index 0000000..76b081c --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchHost.cs @@ -0,0 +1,248 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Management.Automation.Runspaces; +using System.Security; + +namespace Iseberg.Core; + +internal sealed record WorkbenchHostServices(Func ShowCommand, Action ShowCommandError, + Func IsRunspacePushed, IseSnippetService Snippets); + +internal sealed class WorkbenchHost : PSHost, IHostSupportsInteractiveSession +{ + private readonly Guid id = Guid.NewGuid(); + private readonly WorkbenchHostUi ui; + private readonly PSObject privateData; + private readonly Func currentRunspace; + private readonly Func isPushed; + private readonly Action push; + private readonly Action pop; + private readonly Action enterNested; + private readonly Action exitNested; + + public WorkbenchHost(Action write, Func read, + Action progress, Action clear, Func showCommand, + Func currentRunspace, Func isPushed, Action push, Action pop, + Action enterNested, Action exitNested, Action showCommandError, IseSnippetService snippets) + { + ui = new(write, read, progress, clear); + privateData = PSObject.AsPSObject(new WorkbenchHostServices(showCommand, showCommandError, isPushed, snippets)); + this.currentRunspace = currentRunspace; + this.isPushed = isPushed; + this.push = push; + this.pop = pop; + this.enterNested = enterNested; + this.exitNested = exitNested; + } + + public bool IsRunspacePushed => isPushed(); + public Runspace Runspace => currentRunspace(); + public void PushRunspace(Runspace runspace) => push(runspace); + public void PopRunspace() => pop(); + public override PSObject PrivateData => privateData; + + public override Guid InstanceId => id; + public override string Name => "Iseberg"; + public override Version Version => new(1, 0); + public override PSHostUserInterface UI => ui; + public override CultureInfo CurrentCulture => CultureInfo.CurrentCulture; + public override CultureInfo CurrentUICulture => CultureInfo.CurrentUICulture; + public override void SetShouldExit(int exitCode) + { + if (IsRunspacePushed) PopRunspace(); + else ui.WriteWarningLine($"The script requested exit ({exitCode}). The editor session remains open."); + } + public override void EnterNestedPrompt() => enterNested(); + public override void ExitNestedPrompt() => exitNested(); + public override void NotifyBeginApplication() => + ui.WriteWarningLine("Native output is rendered in this workbench, but interactive native applications require a PTY. Use an ordinary DT terminal profile for those applications."); + public override void NotifyEndApplication() { } + internal void SetTerminalSize(int columns, int rows) => ui.SetTerminalSize(columns, rows); +} + +internal sealed class WorkbenchHostUi( + Action write, Func read, + Action progress, Action clear) : PSHostUserInterface, IHostUISupportsMultipleChoiceSelection +{ + private readonly WorkbenchRawUi raw = new(clear); + internal void SetTerminalSize(int columns, int rows) => raw.SetTerminalSize(columns, rows); + public override bool SupportsVirtualTerminal => true; + public override PSHostRawUserInterface RawUI => raw; + public override string ReadLine() => read(new("PowerShell", "Enter a value:")); + public override SecureString ReadLineAsSecureString() => ToSecure(read(new("PowerShell", "Enter a secure value:", true))); + public override void Write(string value) => write(new(value, Style: raw.DefaultStyle)); + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) => + write(new(value, Style: new(AnsiOutputParser.ConsoleColorHex(foregroundColor), AnsiOutputParser.ConsoleColorHex(backgroundColor)))); + public override void WriteLine(string value) => Write(value + Environment.NewLine); + public override void WriteErrorLine(string value) => write(new(value + Environment.NewLine, OutputKind.Error)); + public override void WriteWarningLine(string message) => write(new("WARNING: " + message + Environment.NewLine, OutputKind.Warning)); + public override void WriteVerboseLine(string message) => write(new("VERBOSE: " + message + Environment.NewLine, OutputKind.Verbose)); + public override void WriteDebugLine(string message) => write(new("DEBUG: " + message + Environment.NewLine, OutputKind.Debug)); + public override void WriteProgress(long sourceId, ProgressRecord record) => + progress(new(record.Activity, record.StatusDescription, record.PercentComplete, record.RecordType == ProgressRecordType.Completed, + sourceId, record.ActivityId, record.ParentActivityId, record.SecondsRemaining, record.CurrentOperation)); + + public override Dictionary Prompt(string caption, string message, Collection descriptions) + { + var values = new Dictionary(); + foreach (var field in descriptions) + { + var secret = field.ParameterTypeFullName == typeof(SecureString).FullName; + var input = read(new(caption, $"{message}\n{field.Name}: {field.HelpMessage}", secret)); + object value = secret ? ToSecure(input) : input; + if (!secret && !string.IsNullOrEmpty(field.ParameterTypeFullName)) + { + var type = Type.GetType(field.ParameterTypeFullName); + if (type is not null) + value = LanguagePrimitives.ConvertTo(input, type, CultureInfo.CurrentCulture); + } + values[field.Name] = PSObject.AsPSObject(value); + } + return values; + } + + public override int PromptForChoice(string caption, string message, Collection choices, int defaultChoice) + { + ValidateChoices(choices); + if (defaultChoice < -1 || defaultChoice >= choices.Count) + throw new ArgumentOutOfRangeException(nameof(defaultChoice)); + while (true) + { + var value = read(new(caption, message) + { + Choices = choices.Select(choice => new PromptChoice(choice.Label.Replace("&", ""), choice.HelpMessage)).ToArray(), + DefaultChoices = defaultChoice >= 0 && defaultChoice < choices.Count ? [defaultChoice] : [] + }); + if (string.IsNullOrWhiteSpace(value) && defaultChoice >= 0 && defaultChoice < choices.Count) + return defaultChoice; + var index = ChoiceIndex(value, choices); + if (index >= 0 && index < choices.Count) return index; + WriteWarningLine("Enter a choice number or its label."); + } + } + + public Collection PromptForChoice(string? caption, string? message, Collection choices, + IEnumerable? defaultChoices) + { + ValidateChoices(choices); + var defaults = defaultChoices?.Distinct().ToArray() ?? []; + if (defaults.Any(index => index < 0 || index >= choices.Count)) + throw new ArgumentOutOfRangeException(nameof(defaultChoices)); + while (true) + { + var value = read(new(caption ?? "PowerShell", message ?? "") + { + Choices = choices.Select(choice => new PromptChoice(choice.Label.Replace("&", ""), choice.HelpMessage)).ToArray(), + MultipleChoice = true, DefaultChoices = defaults + }); + if (value == "-") return []; + if (string.IsNullOrWhiteSpace(value)) return new(defaults.ToList()); + var indices = new List(); + foreach (var part in value.Split(',')) + { + var index = ChoiceIndex(part.Trim(), choices); + if (index < 0 || index >= choices.Count) { indices.Clear(); break; } + if (!indices.Contains(index)) indices.Add(index); + } + if (indices.Count > 0) return new(indices); + WriteWarningLine("Enter comma-separated choice numbers or labels."); + } + } + + private static void ValidateChoices(Collection choices) + { + ArgumentNullException.ThrowIfNull(choices); + if (choices.Count == 0) throw new ArgumentException("At least one choice is required.", nameof(choices)); + } + + private static int ChoiceIndex(string value, Collection choices) + { + if (int.TryParse(value, out var index)) return index; + for (var i = 0; i < choices.Count; i++) + { + var label = choices[i].Label; + var marker = label.IndexOf('&'); + if (string.Equals(value, label.Replace("&", ""), StringComparison.OrdinalIgnoreCase) || + marker >= 0 && marker + 1 < label.Length && + string.Equals(value, label.Substring(marker + 1, 1), StringComparison.OrdinalIgnoreCase)) + return i; + } + return -1; + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) => + PromptForCredential(caption, message, userName, targetName, PSCredentialTypes.Default, PSCredentialUIOptions.Default); + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, + PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) + { + var name = read(new(caption, $"{message}\nUser name ({userName}):")); + return new(string.IsNullOrWhiteSpace(name) ? userName : name, ToSecure(read(new(caption, "Password:", true)))); + } + + private static SecureString ToSecure(string value) + { + var secure = new SecureString(); + foreach (var character in value) + secure.AppendChar(character); + secure.MakeReadOnly(); + return secure; + } +} + +internal sealed class WorkbenchRawUi(Action clear) : PSHostRawUserInterface +{ + private ConsoleColor foreground = ConsoleColor.White; + private ConsoleColor background = ConsoleColor.DarkBlue; + private bool foregroundChanged; + private bool backgroundChanged; + private Size terminalSize = new(120, 40); + internal void SetTerminalSize(int columns, int rows) + { + ArgumentOutOfRangeException.ThrowIfLessThan(columns, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + terminalSize = new(columns, rows); + } + internal OutputStyle? DefaultStyle => foregroundChanged || backgroundChanged + ? new(foregroundChanged ? AnsiOutputParser.ConsoleColorHex(foreground) : null, + backgroundChanged ? AnsiOutputParser.ConsoleColorHex(background) : null) : null; + public override ConsoleColor ForegroundColor + { + get => foreground; + set { _ = AnsiOutputParser.ConsoleColorHex(value); foreground = value; foregroundChanged = true; } + } + public override ConsoleColor BackgroundColor + { + get => background; + set { _ = AnsiOutputParser.ConsoleColorHex(value); background = value; backgroundChanged = true; } + } + public override Coordinates CursorPosition { get => new(0, 0); set => throw Unsupported(); } + public override Coordinates WindowPosition { get => new(0, 0); set => throw Unsupported(); } + public override int CursorSize { get => 25; set => throw Unsupported(); } + public override Size BufferSize { get => new(terminalSize.Width, Math.Max(terminalSize.Height, 3000)); set => throw Unsupported(); } + public override Size WindowSize { get => terminalSize; set => throw Unsupported(); } + public override Size MaxWindowSize => terminalSize; + public override Size MaxPhysicalWindowSize => MaxWindowSize; + public override bool KeyAvailable => false; + public override string WindowTitle { get; set; } = "Iseberg"; + public override void FlushInputBuffer() => throw Unsupported(); + public override KeyInfo ReadKey(ReadKeyOptions options) => + throw new PSNotSupportedException("This graphical host does not provide raw keyboard input. Use Read-Host."); + public override BufferCell[,] GetBufferContents(Rectangle rectangle) => + throw new PSNotSupportedException("This graphical host does not expose a character-cell buffer."); + public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) => + throw new PSNotSupportedException("Character-cell output is not supported."); + public override void SetBufferContents(Rectangle rectangle, BufferCell fill) + { + if (rectangle.Left == -1 && rectangle.Top == -1 && rectangle.Right == -1 && rectangle.Bottom == -1) + clear(); + else + throw new PSNotSupportedException("Character-cell output is not supported."); + } + public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) => + throw new PSNotSupportedException("Character-cell scrolling is not supported."); + private static PSNotSupportedException Unsupported() => + new("This protected transcript has no terminal cursor, resizable character buffer or raw keyboard queue. Use Read-Host or Start-IsebergTerminal."); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchPersistenceLease.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchPersistenceLease.cs new file mode 100644 index 0000000..02289b7 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchPersistenceLease.cs @@ -0,0 +1,22 @@ +namespace Iseberg.Core; + +/// Exclusive ownership of one embedded workbench's settings and recovery namespace. +public sealed class WorkbenchPersistenceLease : IDisposable +{ + private readonly FileStream stream; + + public WorkbenchPersistenceLease(string settingsPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(settingsPath); + if (!Path.IsPathFullyQualified(settingsPath)) + throw new ArgumentException("An absolute settings path is required.", nameof(settingsPath)); + var directory = Path.GetDirectoryName(settingsPath)!; + if (OperatingSystem.IsWindows()) Directory.CreateDirectory(directory); + else Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var options = new FileStreamOptions { Mode = FileMode.OpenOrCreate, Access = FileAccess.ReadWrite, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + stream = new FileStream(settingsPath + ".lock", options); + } + + public void Dispose() => stream.Dispose(); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchState.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchState.cs new file mode 100644 index 0000000..d993e5f --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchState.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace Iseberg.Core; + +public sealed class WorkbenchState +{ + public int Version { get; set; } = 1; + public int OwnerProcessId { get; set; } + public DateTime OwnerStartedUtc { get; set; } + public int SelectedSession { get; set; } + public List Sessions { get; set; } = []; + + public void Validate() + { + if (Version != 1 || Sessions is null || + Sessions.Any(session => session is null || !session.Name.StartsWith("PowerShell ", StringComparison.Ordinal) || + !int.TryParse(session.Name["PowerShell ".Length..], out var number) || number <= 0 || + session.Documents is null || session.Documents.Any(document => document is null || + string.IsNullOrWhiteSpace(document.Name) || document.CaretOffset < 0 || document.Encoding is null || + document.Path is not null && !System.IO.Path.IsPathFullyQualified(document.Path))) || + Sessions.Select(session => session.Name).Distinct().Count() != Sessions.Count) + throw new InvalidDataException("The saved workbench configuration is invalid or unsupported."); + foreach (var document in Sessions.SelectMany(session => session.Documents)) + _ = document.Encoding.CreateEncoding(); + } + + public bool OwnerIsRunning() + { + if (OwnerProcessId <= 0) return false; + try + { + using var process = Process.GetProcessById(OwnerProcessId); + return !process.HasExited && process.StartTime.ToUniversalTime() == OwnerStartedUtc; + } + catch (ArgumentException) { return false; } + catch (InvalidOperationException) { return false; } + } +} + +public sealed class WorkbenchSessionState +{ + public string Name { get; set; } = ""; + public int SelectedDocument { get; set; } + public bool ShowDebugger { get; set; } + public List Documents { get; set; } = []; +} + +public sealed class WorkbenchDocumentState +{ + public Guid RecoveryId { get; set; } + public string Name { get; set; } = ""; + public string? Path { get; set; } + public ScriptEncoding Encoding { get; set; } = new(65001, false); + public int CaretOffset { get; set; } +} + +public sealed class WorkbenchStateStore(string? path = null) +{ + public string Path { get; } = path ?? System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Iseberg", "workbench.json"); + + public async Task LoadAsync() + { + if (!File.Exists(Path)) return null; + var state = JsonSerializer.Deserialize(await File.ReadAllTextAsync(Path)) + ?? throw new InvalidDataException("The saved workbench configuration is empty."); + state.Validate(); + return state.OwnerIsRunning() ? null : state; + } + + public async Task SaveAsync(WorkbenchState state) + { + state.Validate(); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + var temporary = Path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await using (var stream = new FileStream(temporary, options)) + await JsonSerializer.SerializeAsync(stream, state, new JsonSerializerOptions { WriteIndented = true }); + File.Move(temporary, Path, overwrite: true); + } + finally { File.Delete(temporary); } + } +} + +public sealed class WorkbenchGeometry +{ + public double WindowWidth { get; set; } = 1180; + public double WindowHeight { get; set; } = 780; + public int? WindowX { get; set; } + public int? WindowY { get; set; } + public bool Maximized { get; set; } + public double TopScriptRatio { get; set; } = 0.6; + public double RightScriptRatio { get; set; } = 0.6; + public double DebuggerWidth { get; set; } = 360; + public double CommandsWidth { get; set; } = 290; + + public void Normalize() + { + WindowWidth = Clamp(WindowWidth, 760, 10000, 1180); + WindowHeight = Clamp(WindowHeight, 500, 10000, 780); + TopScriptRatio = Clamp(TopScriptRatio, 0.1, 0.9, 0.6); + RightScriptRatio = Clamp(RightScriptRatio, 0.1, 0.9, 0.6); + DebuggerWidth = Clamp(DebuggerWidth, 340, 2000, 360); + CommandsWidth = Clamp(CommandsWidth, 150, 2000, 290); + } + + private static double Clamp(double value, double min, double max, double fallback) => + double.IsFinite(value) ? Math.Clamp(value, min, max) : fallback; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/AccessibleTextEditor.cs b/src/Devolutions.Terminal.Ise/Iseberg.Editor/AccessibleTextEditor.cs new file mode 100644 index 0000000..aff03cf --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/AccessibleTextEditor.cs @@ -0,0 +1,50 @@ +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using AvaloniaEdit; + +namespace Iseberg.Editor; + +public sealed class AccessibleTextEditor : TextEditor +{ + protected override Type StyleKeyOverride => typeof(TextEditor); + protected override AutomationPeer OnCreateAutomationPeer() => new EditorPeer(this); + + private sealed class EditorPeer : ControlAutomationPeer, IValueProvider + { + private readonly AccessibleTextEditor editor; + private string lastText; + public EditorPeer(AccessibleTextEditor editor) : base(editor) + { + this.editor = editor; + lastText = editor.Text; + editor.TextChanged += (_, _) => + { + RaisePropertyChangedEvent(ValuePatternIdentifiers.ValueProperty, lastText, editor.Text); + lastText = editor.Text; + }; + editor.PropertyChanged += (_, e) => + { + if (e.Property == IsReadOnlyProperty) + RaisePropertyChangedEvent(ValuePatternIdentifiers.IsReadOnlyProperty, e.OldValue, e.NewValue); + }; + } + public bool IsReadOnly => editor.IsReadOnly; + public string Value => editor.Text; + public void SetValue(string? value) + { + EnsureEnabled(); + if (IsReadOnly) throw new InvalidOperationException("The editor is read-only."); + if (!editor.TextArea.ReadOnlySectionProvider.CanInsert(0) || + editor.TextArea.ReadOnlySectionProvider.GetDeletableSegments(new AvaloniaEdit.Document.SimpleSegment(0, editor.Document.TextLength)) + .Sum(segment => segment.Length) != editor.Document.TextLength) + throw new InvalidOperationException("The editor contains protected text."); + editor.Document.Replace(0, editor.Document.TextLength, value ?? ""); + } + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Edit; + protected override string GetClassNameCore() => nameof(TextEditor); + protected override void SetFocusCore() => editor.TextArea.Focus(); + protected override object? GetProviderCore(Type providerType) => + providerType == typeof(IValueProvider) ? this : base.GetProviderCore(providerType); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorCompletionPopup.cs b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorCompletionPopup.cs new file mode 100644 index 0000000..5864eba --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorCompletionPopup.cs @@ -0,0 +1,64 @@ +using Avalonia.Controls; +using Avalonia.LogicalTree; +using Avalonia.Styling; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.Editing; + +namespace Iseberg.Editor; + +/// Shared styled completion UI for script editors and host-owned protected consoles. No provider or engine calls. +public static class EditorCompletionPopup +{ + public static CompletionWindow Show(TextArea textArea, EditorTextSpan span, IEnumerable items, + bool acceptOnEnter = true, string prefix = "") + { + Avalonia.Threading.Dispatcher.UIThread.VerifyAccess(); + ArgumentNullException.ThrowIfNull(textArea); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(prefix); + span.Validate(textArea.Document.TextLength); + var window = new CompletionWindow(textArea) + { + StartOffset = span.Start, EndOffset = span.End, CloseWhenCaretAtBeginning = false + }; + try + { + // AvaloniaEdit parents popups to the window, bypassing an editor's scoped theme. + var logicalParent = (ISetLogicalParent)window; + logicalParent.SetParent(null); + logicalParent.SetParent(textArea); + var styles = Styles(); + var baseResources = styles.Resources.MergedDictionaries.Single() as IResourceDictionary + ?? throw new InvalidOperationException("The editor template resources are unavailable."); + foreach (var key in new[] { typeof(ListBox), typeof(ListBoxItem) }) + { + if (!textArea.TryFindResource(key, out var resource)) + throw new InvalidOperationException($"The host theme does not provide {key.Name}."); + baseResources[key] = resource; + } + // The description popup is a sibling of the list, not its descendant. + window.Styles.Add(styles); + if (!styles.TryGetResource(typeof(CompletionList), textArea.ActualThemeVariant, out var theme) || + theme is not ControlTheme completionTheme) + throw new InvalidOperationException("The editor completion theme is unavailable."); + window.CompletionList.Theme = completionTheme; + foreach (var item in items) + { + ArgumentNullException.ThrowIfNull(item); + window.CompletionList.CompletionData.Add(item); + } + if (!acceptOnEnter) window.CompletionList.CompletionAcceptKeys = [Avalonia.Input.Key.Tab]; + window.Show(); + window.CompletionList.ApplyTemplate(); + window.CompletionList.SelectItem(prefix); + return window; + } + catch + { + window.Close(); + throw; + } + } + + internal static EditorStyles Styles() => new(); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorContracts.cs b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorContracts.cs new file mode 100644 index 0000000..6a7a238 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorContracts.cs @@ -0,0 +1,64 @@ +using System.Collections.Immutable; + +namespace Iseberg.Editor; + +/// A zero-based UTF-16 offset and length, not a byte or Unicode scalar range. +public readonly record struct EditorTextSpan +{ + public int Start { get; } + public int Length { get; } + public int End => checked(Start + Length); + + public EditorTextSpan(int start, int length) + { + ArgumentOutOfRangeException.ThrowIfNegative(start); + ArgumentOutOfRangeException.ThrowIfNegative(length); + ArgumentOutOfRangeException.ThrowIfGreaterThan(length, int.MaxValue - start); + Start = start; + Length = length; + } + + internal void Validate(int textLength) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(End, textLength); + } +} + +public enum EditorTextScope { Document, SelectionOrCurrentLine } +public enum EditorDiagnosticSeverity { Information, Warning, Error } +public enum EditorAnalysisState { Unavailable, Pending, Available, Failed } +public enum EditorExecutionRequestKind { Document, SelectionOrCurrentLine, Stop } + +/// Immutable captured text. Span locates it in the document at Version. +public sealed record EditorTextSnapshot(string Text, long Version, EditorTextSpan Span); +public sealed record EditorDiagnostic(string Code, string Message, EditorDiagnosticSeverity Severity, EditorTextSpan Span); +public sealed record EditorAnalysisRequest(string Text, long Version); +public sealed record EditorAnalysisResult(long Version, EditorAnalysisState State, ImmutableArray Diagnostics); +public sealed record EditorCompletionRequest(string Text, long Version, int CaretOffset); +public sealed record EditorCompletionItem(string Text, string DisplayText, string? Description = null); +public sealed record EditorCompletionList(long Version, EditorTextSpan ReplacementSpan, ImmutableArray Items); + +/// Host-supplied completion only. No implicit engine or session fallback. +public interface IEditorCompletionProvider +{ + Task CompleteAsync(EditorCompletionRequest request, CancellationToken cancellationToken); +} + +/// Explicit host diagnostics. The editor has no built-in parser or engine fallback. +public interface IEditorAnalysisProvider +{ + Task AnalyzeAsync(EditorAnalysisRequest request, CancellationToken cancellationToken); +} + +public sealed class EditorExecutionRequestedEventArgs(EditorExecutionRequestKind kind, EditorTextSnapshot? snapshot) : EventArgs +{ + public EditorExecutionRequestKind Kind { get; } = kind; + /// Null for Stop: the host, not this editor, owns the invocation. + public EditorTextSnapshot? Snapshot { get; } = snapshot; +} + +public sealed class EditorErrorEventArgs(string operation, Exception exception) : EventArgs +{ + public string Operation { get; } = operation; + public Exception Exception { get; } = exception; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorHighlighting.cs b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorHighlighting.cs new file mode 100644 index 0000000..8f85a81 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorHighlighting.cs @@ -0,0 +1,30 @@ +using System.Xml; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.Highlighting; +using AvaloniaEdit.Highlighting.Xshd; + +namespace Iseberg.Editor; + +internal static class EditorHighlighting +{ + public static IHighlightingDefinition Create(bool dark) + { + using var stream = typeof(TextEditor).Assembly.GetManifestResourceStream("AvaloniaEdit.Highlighting.Resources.PowerShell.xshd") + ?? throw new InvalidOperationException("AvaloniaEdit's PowerShell syntax definition is unavailable."); + using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + var definition = HighlightingLoader.Load(reader, HighlightingManager.Instance); + foreach (var color in definition.NamedHighlightingColors) + color.Foreground = new SimpleHighlightingBrush(Color.Parse(color.Name switch + { + "Comment" => dark ? "#90EE90" : "#006400", + "String" or "Char" => dark ? "#FFCB6B" : "#8B0000", + "Variable" => dark ? "#FFAB70" : "#A63000", + "NumberLiteral" or "ReferenceTypes" => dark ? "#D9A9FF" : "#800080", + "ExceptionKeywords" => dark ? "#80CBC4" : "#007080", + "Punctuation" or "Operators" => dark ? "#F0F0F0" : "#202020", + _ => dark ? "#82AAFF" : "#0000C0" + })); + return definition; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorStyles.axaml b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorStyles.axaml new file mode 100644 index 0000000..793cc0b --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorStyles.axaml @@ -0,0 +1,33 @@ + + + + 14 + Default + + + + + + 1 + + + + + + + + + 1 + + + + + + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorStyles.axaml.cs b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorStyles.axaml.cs new file mode 100644 index 0000000..20d884e --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/EditorStyles.axaml.cs @@ -0,0 +1,10 @@ +using Avalonia.Markup.Xaml; +using Avalonia.Styling; + +namespace Iseberg.Editor; + +/// AvaloniaEdit templates with dynamically resolved, control-scoped Fluent colors. +public sealed partial class EditorStyles : Styles +{ + public EditorStyles() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/Iseberg.Editor.csproj b/src/Devolutions.Terminal.Ise/Iseberg.Editor/Iseberg.Editor.csproj new file mode 100644 index 0000000..e532adc --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/Iseberg.Editor.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Editor/PowerShellEditorControl.cs b/src/Devolutions.Terminal.Ise/Iseberg.Editor/PowerShellEditorControl.cs new file mode 100644 index 0000000..e47eabe --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Editor/PowerShellEditorControl.cs @@ -0,0 +1,579 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Styling; +using Avalonia.Threading; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Search; + +namespace Iseberg.Editor; + +/// Engine-independent text editing. All operations and document mutations require the UI thread. +public sealed class PowerShellEditorControl : UserControl, IDisposable +{ + public static readonly DirectProperty DocumentProperty = + AvaloniaProperty.RegisterDirect(nameof(Document), control => control.Document, + (control, value) => control.Document = value); + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + public static readonly StyledProperty EnableExecutionGesturesProperty = + AvaloniaProperty.Register(nameof(EnableExecutionGestures)); + public static readonly StyledProperty EnableSyntaxHighlightingProperty = + AvaloniaProperty.Register(nameof(EnableSyntaxHighlighting), true); + public static readonly StyledProperty EditorNameProperty = + AvaloniaProperty.Register(nameof(EditorName), "PowerShell script editor"); + + private readonly AccessibleTextEditor editor = new() { ShowLineNumbers = true }; + private readonly SearchPanel search; + private readonly EditorStyles editorStyles; + private TextDocument document = new(); + private ITextSourceVersion? observedVersion; + private CancellationTokenSource? analysisCancellation; + private CancellationTokenSource? completionCancellation; + private CompletionWindow? completionWindow; + private PopupContext? popupContext; + private bool enteringText; + private IEditorCompletionProvider? completionProvider; + private IEditorAnalysisProvider? analysisProvider; + private EditorAnalysisResult analysis = new(0, EditorAnalysisState.Unavailable, []); + private long version; + private long lifetime; + private bool attached; + private bool hasAttached; + private bool disposed; + private int savedCaret; + private int savedSelectionStart; + private int savedSelectionLength; + + public PowerShellEditorControl() + { + Dispatcher.UIThread.VerifyAccess(); + observedVersion = document.Version; + editor.Document = document; + search = SearchPanel.Install(editor); + editorStyles = EditorCompletionPopup.Styles(); + Styles.Add(editorStyles); + Content = editor; + AutomationProperties.SetName(editor, EditorName); + editor.TextArea.Caret.PositionChanged += OnCaretChanged; + editor.TextArea.TextEntering += OnTextEntering; + editor.TextArea.TextEntered += OnTextEntered; + editor.AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + ActualThemeVariantChanged += (_, _) => UpdateColors(); + UpdateColors(); + } + + public PowerShellEditorControl(TextDocument document) : this() => Document = document; + + /// Host-owned, never disposed or cleared by this control. Replacing it invalidates pending work. + public TextDocument Document + { + get => document; + set + { + VerifyUsable(); + ArgumentNullException.ThrowIfNull(value); + if (ReferenceEquals(document, value)) return; + UnsubscribeDocument(); + CancelWork(); + var previous = document; + document = value; + observedVersion = value.Version; + version++; + savedCaret = savedSelectionStart = savedSelectionLength = 0; + editor.Document = attached || !hasAttached ? value : new TextDocument(); + InvalidateAnalysis(); + if (attached) + { + SubscribeDocument(); + Observe(AnalyzeCoreAsync(CancellationToken.None, debounce: true), "Analysis"); + } + RaisePropertyChanged(DocumentProperty, previous, value); + } + } + + public bool IsReadOnly { get => GetValue(IsReadOnlyProperty); set => SetValue(IsReadOnlyProperty, value); } + public bool EnableExecutionGestures { get => GetValue(EnableExecutionGesturesProperty); set => SetValue(EnableExecutionGesturesProperty, value); } + /// Disable for host high-contrast palettes. No application resources are modified. + public bool EnableSyntaxHighlighting { get => GetValue(EnableSyntaxHighlightingProperty); set => SetValue(EnableSyntaxHighlightingProperty, value); } + public string EditorName { get => GetValue(EditorNameProperty); set => SetValue(EditorNameProperty, value); } + /// Advanced host extensions: options, margins, rendering, scroll and edit commands. Assign Document/IsReadOnly through this control. + public AccessibleTextEditor TextEditor => editor; + public bool CompletionAcceptsEnter { get; set; } = true; + public bool IsCompletionOpen => completionWindow is not null; + public CompletionWindow? CompletionPopup => completionWindow; + public long DocumentVersion { get { VerifyUsable(); SynchronizeVersion(); return version; } } + /// Changes on detach/dispose. Pair with a captured document version for async host adornments. + public long EditorLifetime { get { VerifyUsable(); return lifetime; } } + public bool IsEditorAttached { get { VerifyUsable(); return attached; } } + + public bool IsSnapshotCurrent(long documentVersion, long editorLifetime) + { + Dispatcher.UIThread.VerifyAccess(); + if (disposed || !attached || lifetime != editorLifetime) return false; + SynchronizeVersion(); + return version == documentVersion; + } + + /// Hit-tests editor-relative coordinates without mapping virtual space to a nearby token. + public bool TryGetOffsetFromPoint(Point editorPoint, out int offset) + { + VerifyUsable(); + offset = 0; + if (!attached || editor.GetPositionFromPoint(editorPoint) is not { } position) return false; + var line = document.GetLineByNumber(position.Line); + if (position.Column < 1 || position.Column > line.Length) return false; + offset = document.GetOffset(position.Location); + return true; + } + public EditorAnalysisResult Analysis + { + get { Dispatcher.UIThread.VerifyAccess(); if (!disposed) SynchronizeVersion(); return analysis; } + private set => analysis = value; + } + public IEditorCompletionProvider? CompletionProvider + { + get => completionProvider; + set { VerifyUsable(); CancelCompletion(); completionProvider = value; } + } + public IEditorAnalysisProvider? AnalysisProvider + { + get => analysisProvider; + set + { + VerifyUsable(); + analysisCancellation?.Cancel(); + analysisProvider = value; + InvalidateAnalysis(); + if (attached) Observe(AnalyzeCoreAsync(CancellationToken.None, debounce: true), "Analysis"); + } + } + + public int CaretOffset + { + get { VerifyUsable(); return attached ? editor.CaretOffset : Math.Min(savedCaret, document.TextLength); } + set + { + VerifyUsable(); + new EditorTextSpan(value, 0).Validate(document.TextLength); + CancelCompletion(); + savedCaret = value; + if (attached) editor.CaretOffset = value; + } + } + public EditorTextSpan Selection + { + get + { + VerifyUsable(); + return attached ? new(editor.SelectionStart, editor.SelectionLength) : + new(Math.Min(savedSelectionStart, document.TextLength), Math.Min(savedSelectionLength, document.TextLength - Math.Min(savedSelectionStart, document.TextLength))); + } + } + + public event EventHandler? AnalysisChanged; + public event EventHandler? ExecutionRequested; + /// Background/keyboard failures. Explicit awaited operations instead propagate their exceptions. + public event EventHandler? ErrorOccurred; + + public void Select(EditorTextSpan span) + { + VerifyUsable(); + span.Validate(document.TextLength); + CancelCompletion(); + savedSelectionStart = span.Start; + savedSelectionLength = span.Length; + savedCaret = span.End; + if (attached) editor.Select(span.Start, span.Length); + } + + public bool FocusEditor() + { + VerifyUsable(); + return editor.TextArea.Focus(); + } + + public void ShowFind() + { + VerifyUsable(); + search.Open(); + search.Reactivate(); + } + + public void CloseCompletion() + { + VerifyUsable(); + CancelCompletion(); + } + + public EditorTextSnapshot CaptureText(EditorTextScope scope = EditorTextScope.Document) + { + VerifyUsable(); + SynchronizeVersion(); + var span = scope switch + { + EditorTextScope.Document => new EditorTextSpan(0, document.TextLength), + EditorTextScope.SelectionOrCurrentLine when Selection.Length > 0 => Selection, + EditorTextScope.SelectionOrCurrentLine => LineSpan(), + _ => throw new ArgumentOutOfRangeException(nameof(scope)) + }; + return new(document.GetText(span.Start, span.Length), version, span); + } + + public Task AnalyzeAsync(CancellationToken cancellationToken = default) + { + VerifyUsable(); + return AnalyzeCoreAsync(cancellationToken, debounce: false); + } + + private async Task AnalyzeCoreAsync(CancellationToken cancellationToken, bool debounce) + { + SynchronizeVersion(); + analysisCancellation?.Cancel(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + analysisCancellation = cancellation; + var snapshot = CaptureText(); + var generation = lifetime; + var provider = analysisProvider; + try + { + cancellation.Token.ThrowIfCancellationRequested(); + if (provider is null) + { + PublishAnalysis(new(version, EditorAnalysisState.Unavailable, [])); + return analysis; + } + PublishAnalysis(new(snapshot.Version, EditorAnalysisState.Pending, [])); + if (debounce) await Task.Delay(100, cancellation.Token); + var result = await provider.AnalyzeAsync(new(snapshot.Text, snapshot.Version), cancellation.Token); + cancellation.Token.ThrowIfCancellationRequested(); + SynchronizeVersion(); + if (disposed || generation != lifetime || snapshot.Version != version) + throw new OperationCanceledException("The analysis snapshot is stale."); + ArgumentNullException.ThrowIfNull(result); + if (result.Version != snapshot.Version || result.State is not (EditorAnalysisState.Available or EditorAnalysisState.Unavailable)) + throw new InvalidOperationException("Analysis provider returned an invalid version or state."); + if (result.Diagnostics.IsDefault || result.State == EditorAnalysisState.Unavailable && !result.Diagnostics.IsEmpty) + throw new InvalidOperationException("Analysis provider returned invalid diagnostics."); + foreach (var diagnostic in result.Diagnostics) + { + if (diagnostic is null || diagnostic.Code is null || diagnostic.Message is null || !Enum.IsDefined(diagnostic.Severity)) + throw new InvalidOperationException("Analysis provider returned an invalid diagnostic."); + diagnostic.Span.Validate(snapshot.Text.Length); + } + PublishAnalysis(result); + cancellation.Token.ThrowIfCancellationRequested(); + return result; + } + catch (Exception) when (cancellation.IsCancellationRequested) + { + MarkAnalysisCancelled(generation, snapshot.Version, cancellation); + throw new OperationCanceledException(cancellation.Token); + } + catch (OperationCanceledException) + { + MarkAnalysisCancelled(generation, snapshot.Version, cancellation); + throw; + } + catch + { + if (!disposed && generation == lifetime && snapshot.Version == version) + PublishAnalysis(new(version, EditorAnalysisState.Failed, [])); + throw; + } + finally + { + if (ReferenceEquals(analysisCancellation, cancellation)) analysisCancellation = null; + } + } + + private void MarkAnalysisCancelled(long generation, long snapshotVersion, CancellationTokenSource cancellation) + { + if (!disposed && generation == lifetime && snapshotVersion == version && ReferenceEquals(analysisCancellation, cancellation)) + PublishAnalysis(new(version, EditorAnalysisState.Unavailable, [])); + } + + /// Requests only the injected provider; validates and rejects stale results. Does not execute PowerShell. + public async Task RequestCompletionAsync(CancellationToken cancellationToken = default) + { + VerifyUsable(); + var provider = completionProvider ?? throw new InvalidOperationException("No completion provider is configured."); + CancelCompletion(); + var snapshot = CaptureText(); + var caret = CaretOffset; + var generation = lifetime; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + completionCancellation = cancellation; + try + { + cancellation.Token.ThrowIfCancellationRequested(); + var result = await provider.CompleteAsync(new(snapshot.Text, snapshot.Version, caret), cancellation.Token); + cancellation.Token.ThrowIfCancellationRequested(); + SynchronizeVersion(); + if (disposed || generation != lifetime || version != snapshot.Version || CaretOffset != caret) + throw new OperationCanceledException("The completion snapshot is stale."); + ArgumentNullException.ThrowIfNull(result); + if (result.Version != snapshot.Version) + throw new InvalidOperationException("Completion provider returned the wrong document version."); + result.ReplacementSpan.Validate(snapshot.Text.Length); + if (result.Items.IsDefault || result.Items.Any(item => item is null || item.Text is null || item.DisplayText is null)) + throw new InvalidOperationException("Completion provider returned invalid items."); + return result; + } + catch (Exception) when (cancellation.IsCancellationRequested) + { + throw new OperationCanceledException(cancellation.Token); + } + finally + { + if (ReferenceEquals(completionCancellation, cancellation)) completionCancellation = null; + } + } + + public void ApplyCompletion(EditorCompletionList completion, int itemIndex) + { + VerifyUsable(); + ArgumentNullException.ThrowIfNull(completion); + SynchronizeVersion(); + if (IsReadOnly) throw new InvalidOperationException("The editor is read-only."); + if (completion.Version != version) throw new InvalidOperationException("The completion snapshot is stale."); + completion.ReplacementSpan.Validate(document.TextLength); + if (completion.Items.IsDefault) throw new ArgumentException("Completion items are uninitialized.", nameof(completion)); + ArgumentOutOfRangeException.ThrowIfNegative(itemIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(itemIndex, completion.Items.Length); + var item = completion.Items[itemIndex] ?? throw new ArgumentException("Completion item is null.", nameof(completion)); + ArgumentNullException.ThrowIfNull(item.Text); + var protection = editor.TextArea.ReadOnlySectionProvider; + if (!protection.CanInsert(completion.ReplacementSpan.Start) || + protection.GetDeletableSegments(new SimpleSegment(completion.ReplacementSpan.Start, completion.ReplacementSpan.Length)) + .Sum(segment => segment.Length) != completion.ReplacementSpan.Length) + throw new InvalidOperationException("The completion would replace protected text."); + document.Replace(completion.ReplacementSpan.Start, completion.ReplacementSpan.Length, item.Text); + CaretOffset = completion.ReplacementSpan.Start + item.Text.Length; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (disposed) return; + var selection = Selection; + SynchronizeVersion(); + editor.Document = document; + editor.Select(selection.Start, selection.Length); + editor.CaretOffset = Math.Min(savedCaret, document.TextLength); + attached = true; + hasAttached = true; + SubscribeDocument(); + Observe(AnalyzeCoreAsync(CancellationToken.None, debounce: true), "Analysis"); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (disposed) + { + attached = false; + base.OnDetachedFromVisualTree(e); + return; + } + savedCaret = editor.CaretOffset; + savedSelectionStart = editor.SelectionStart; + savedSelectionLength = editor.SelectionLength; + attached = false; + lifetime++; + CancelWork(); + UnsubscribeDocument(); + // Native IME observers can query the text area during the caret reset, before focus is released. + editor.Document = new TextDocument(); + InvalidateAnalysis(); + base.OnDetachedFromVisualTree(e); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == IsReadOnlyProperty) + { + editor.IsReadOnly = IsReadOnly; + CancelCompletion(); + } + else if (change.Property == EditorNameProperty) AutomationProperties.SetName(editor, EditorName); + else if (change.Property == EnableSyntaxHighlightingProperty) UpdateColors(); + } + + private void UpdateColors() + { + editor.SyntaxHighlighting = EnableSyntaxHighlighting ? EditorHighlighting.Create(ActualThemeVariant == ThemeVariant.Dark) : null; + } + + private EditorTextSpan LineSpan() + { + var line = document.GetLineByOffset(CaretOffset); + return new(line.Offset, line.Length); + } + + private void SubscribeDocument() { document.Changed -= OnDocumentChanged; document.Changed += OnDocumentChanged; } + private void UnsubscribeDocument() => document.Changed -= OnDocumentChanged; + private void OnDocumentChanged(object? sender, DocumentChangeEventArgs args) + { + SynchronizeVersion(); + completionCancellation?.Cancel(); + if (completionWindow is not null && popupContext is { Valid: true } context && enteringText && + args.RemovalLength == 0 && args.Offset >= context.Span.Start && args.Offset <= context.Span.End && + !args.InsertedText.Text.Contains('\n') && !args.InsertedText.Text.Contains('\r')) + { + context.Span = new(context.Span.Start, context.Span.Length + args.InsertionLength); + context.Version = version; + } + else CancelCompletion(); + InvalidateAnalysis(); + Observe(AnalyzeCoreAsync(CancellationToken.None, debounce: true), "Analysis"); + } + + private void SynchronizeVersion() + { + if (observedVersion?.CompareAge(document.Version) == 0) return; + observedVersion = document.Version; + version++; + InvalidateAnalysis(); + } + + private void InvalidateAnalysis() + { + PublishAnalysis(new(version, attached && analysisProvider is not null ? EditorAnalysisState.Pending : EditorAnalysisState.Unavailable, [])); + } + + private void PublishAnalysis(EditorAnalysisResult result) + { + if (analysis == result) return; + Analysis = result; + AnalysisChanged?.Invoke(this, EventArgs.Empty); + } + + private void OnTextEntering(object? sender, TextInputEventArgs args) => enteringText = true; + private void OnTextEntered(object? sender, TextInputEventArgs args) => enteringText = false; + private void OnCaretChanged(object? sender, EventArgs args) + { + completionCancellation?.Cancel(); + if (!enteringText) CancelCompletion(); + } + private void CancelCompletion() + { + completionCancellation?.Cancel(); + if (popupContext is not null) popupContext.Valid = false; + popupContext = null; + completionWindow?.Close(); + completionWindow = null; + } + private void CancelWork() { analysisCancellation?.Cancel(); CancelCompletion(); } + + private void OnKeyDown(object? sender, KeyEventArgs args) + { + if (disposed) return; + if (args.Key == Key.Space && args.KeyModifiers == KeyModifiers.Control && CompletionProvider is not null && !IsReadOnly) + { + args.Handled = true; + Observe(ShowCompletionAsync(), "Completion"); + } + else if (EnableExecutionGestures && args.KeyModifiers == KeyModifiers.None && args.Key is Key.F5 or Key.F8 || + EnableExecutionGestures && args.KeyModifiers == KeyModifiers.Control && args.Key == Key.Pause) + { + args.Handled = true; + var kind = args.Key == Key.F5 ? EditorExecutionRequestKind.Document : + args.Key == Key.F8 ? EditorExecutionRequestKind.SelectionOrCurrentLine : EditorExecutionRequestKind.Stop; + ExecutionRequested?.Invoke(this, new(kind, kind == EditorExecutionRequestKind.Stop ? null : + CaptureText(kind == EditorExecutionRequestKind.Document ? EditorTextScope.Document : EditorTextScope.SelectionOrCurrentLine))); + } + } + + public async Task ShowCompletionAsync(CancellationToken cancellationToken = default) + { + var result = await RequestCompletionAsync(cancellationToken); + if (!attached || result.Items.IsEmpty) return; + var context = new PopupContext(result.Version, result.ReplacementSpan, lifetime); + var window = EditorCompletionPopup.Show(editor.TextArea, result.ReplacementSpan, + result.Items.Select(item => new CompletionData(this, result, item, context)), CompletionAcceptsEnter, + document.GetText(result.ReplacementSpan.Start, Math.Clamp(CaretOffset - result.ReplacementSpan.Start, 0, result.ReplacementSpan.Length))); + completionWindow = window; + popupContext = context; + window.Closed += (_, _) => + { + if (ReferenceEquals(completionWindow, window)) { completionWindow = null; popupContext = null; } + }; + } + + private async void Observe(Task task, string operation) + { + try { await task; } + catch (OperationCanceledException) { } + catch (Exception exception) + { + ReportError(operation, exception); + } + } + + private void ReportError(string operation, Exception exception) + { + Trace.TraceError("Iseberg editor {0}: {1}", operation, exception); + ErrorOccurred?.Invoke(this, new(operation, exception)); + } + + private void VerifyUsable() + { + Dispatcher.UIThread.VerifyAccess(); + ObjectDisposedException.ThrowIf(disposed, this); + } + + public void Dispose() + { + Dispatcher.UIThread.VerifyAccess(); + if (disposed) return; + disposed = true; + attached = false; + lifetime++; + CancelWork(); + UnsubscribeDocument(); + search.Uninstall(); + editor.TextArea.Caret.PositionChanged -= OnCaretChanged; + editor.TextArea.TextEntering -= OnTextEntering; + editor.TextArea.TextEntered -= OnTextEntered; + editor.RemoveHandler(KeyDownEvent, OnKeyDown); + editor.Document = new TextDocument(); + editor.IsReadOnly = true; + Analysis = new(version, EditorAnalysisState.Unavailable, []); + analysisProvider = null; + completionProvider = null; + } + + private sealed class PopupContext(long version, EditorTextSpan span, long lifetime) + { + public long Version { get; set; } = version; + public EditorTextSpan Span { get; set; } = span; + public long Lifetime { get; } = lifetime; + public bool Valid { get; set; } = true; + } + + private sealed class CompletionData(PowerShellEditorControl owner, EditorCompletionList list, EditorCompletionItem item, PopupContext context) : ICompletionData + { + public Avalonia.Media.IImage? Image => null; + public string Text => item.Text; + public object Content => item.DisplayText; + public object Description => item.Description ?? ""; + public double Priority => 0; + public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) + { + try + { + if (!context.Valid || context.Lifetime != owner.lifetime || context.Version != owner.DocumentVersion || + context.Span != new EditorTextSpan(completionSegment.Offset, completionSegment.Length)) + throw new InvalidOperationException("The completion popup snapshot is stale."); + owner.ApplyCompletion(list with { Version = context.Version, ReplacementSpan = context.Span }, list.Items.IndexOf(item)); + } + catch (Exception exception) { owner.ReportError("Completion", exception); } + } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/AppIcon.cs b/src/Devolutions.Terminal.Ise/Iseberg/AppIcon.cs new file mode 100644 index 0000000..5066798 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/AppIcon.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; + +namespace Iseberg; + +public static class AppIcon +{ + public static WindowIcon Create() + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true); + writer.Write((ushort)0); writer.Write((ushort)1); writer.Write((ushort)1); + writer.Write((byte)16); writer.Write((byte)16); writer.Write((ushort)0); + writer.Write((ushort)1); writer.Write((ushort)32); writer.Write(40 + 16 * 16 * 4 + 16 * 4); writer.Write(22); + writer.Write(40); writer.Write(16); writer.Write(32); writer.Write((ushort)1); writer.Write((ushort)32); + writer.Write(0); writer.Write(16 * 16 * 4); writer.Write(0); writer.Write(0); writer.Write(0); writer.Write(0); + for (var y = 15; y >= 0; y--) + for (var x = 0; x < 16; x++) + { + var foreground = (y is >= 4 and <= 7 && x == y - 1) || (y is >= 8 and <= 11 && x == 14 - y) || + (y == 11 && x is >= 8 and <= 12); + writer.Write(foreground ? (byte)255 : (byte)170); + writer.Write(foreground ? (byte)255 : (byte)125); + writer.Write(foreground ? (byte)255 : (byte)25); + writer.Write((byte)255); + } + writer.Write(new byte[64]); + stream.Position = 0; + return new WindowIcon(stream); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/BreakpointMargin.cs b/src/Devolutions.Terminal.Ise/Iseberg/BreakpointMargin.cs new file mode 100644 index 0000000..05aac42 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/BreakpointMargin.cs @@ -0,0 +1,81 @@ +using Avalonia; +using Avalonia.Input; +using Avalonia.Media; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using Iseberg.Core; + +namespace Iseberg; + +public sealed class BreakpointMargin(Func file, Func debug, Func theme) : AbstractMargin +{ + public event Action? BreakpointRequested; + + protected override Size MeasureOverride(Size availableSize) => new(20, 0); + + public override void Render(DrawingContext context) + { + base.Render(context); + var background = DesktopTheme.HighContrast ? DesktopTheme.Brush("WindowBrush") : + new SolidColorBrush(Color.Parse(theme().Colors["Script.Background"])); + context.DrawRectangle(background, null, new Rect(Bounds.Size)); + if (file() is not { } current || TextView is not { VisualLinesValid: true } view) return; + var breakpoints = current.LineBreakpoints.ToDictionary(spec => spec.Line); + foreach (var line in view.VisualLines) + { + var number = line.FirstDocumentLine.LineNumber; + var y = line.VisualTop - view.VerticalOffset + line.TextLines[0].Height / 2; + var center = new Point(Bounds.Width / 2, y); + var radius = Math.Clamp(line.TextLines[0].Height * .3, 1, 5); + if (breakpoints.TryGetValue(number, out var breakpoint)) + { + var brush = BreakpointVisuals.MarkerBrush(theme(), paused: false); + context.DrawEllipse(breakpoint.Enabled ? brush : null, new Pen(brush, 1.5), center, radius, radius); + } + if (BreakpointVisuals.IsPausedLine(current, debug(), number)) + { + var arrow = new StreamGeometry(); + using (var geometry = arrow.Open()) + { + geometry.BeginFigure(new Point(center.X - radius, y - radius * .8), true); + geometry.LineTo(new Point(center.X + radius, y)); + geometry.LineTo(new Point(center.X - radius, y + radius * .8)); + geometry.EndFigure(true); + } + var brush = BreakpointVisuals.MarkerBrush(theme(), paused: true); + context.DrawGeometry(brush, new Pen(background, 1), arrow); + } + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || TextView is not { } view || + file() is null) return; + view.EnsureVisualLines(); + var point = e.GetPosition(view); + if (point.Y < 0 || point.Y >= view.Bounds.Height || point.Y + view.VerticalOffset >= view.DocumentHeight) return; + var line = view.GetVisualLineFromVisualTop(point.Y + view.VerticalOffset); + if (line is null) return; + e.Handled = true; + BreakpointRequested?.Invoke(line.FirstDocumentLine.LineNumber); + } +} + +internal static class BreakpointVisuals +{ + public static bool IsPausedLine(ScriptTab current, DebugLocation? location, int line) => + location is not null && location.Line == line && + string.Equals(location.ScriptPath, current.File.Path, !current.File.IsRemote && OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + public static Color MarkerColor(EditorTheme theme, bool paused) + { + var background = Color.Parse(theme.Colors["Script.Background"]); + var dark = (background.R * 299 + background.G * 587 + background.B * 114) < 128000; + return Color.Parse(paused ? dark ? "#FFD54F" : "#A66A00" : dark ? "#FF6B6B" : "#B71C1C"); + } + + public static IBrush MarkerBrush(EditorTheme theme, bool paused) => + DesktopTheme.HighContrast ? DesktopTheme.Brush("WindowTextBrush") : new SolidColorBrush(MarkerColor(theme, paused)); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/BreakpointWindow.cs b/src/Devolutions.Terminal.Ise/Iseberg/BreakpointWindow.cs new file mode 100644 index 0000000..653dfe6 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/BreakpointWindow.cs @@ -0,0 +1,95 @@ +using System.Management.Automation; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Iseberg.Core; + +namespace Iseberg; + +public sealed class BreakpointWindow : Window +{ + private readonly ComboBox kind = new() { Name = "BreakpointKind", ItemsSource = Enum.GetValues() }; + private readonly TextBox script = new() { Name = "BreakpointScript" }; + private readonly TextBox target = new() { Name = "BreakpointTarget" }; + private readonly TextBox line = new() { Name = "BreakpointLine" }; + private readonly TextBox condition = new() { Name = "BreakpointCondition" }; + private readonly TextBox action = new() { Name = "BreakpointAction" }; + private readonly ComboBox access = new() { Name = "BreakpointAccess", ItemsSource = Enum.GetValues() }; + private readonly CheckBox enabled = new() { Name = "BreakpointEnabled", Content = UiText.Get("BreakpointEnabled") }; + private readonly TextBlock error = new() { TextWrapping = TextWrapping.Wrap }; + + public BreakpointWindow(BreakpointSpec spec) + { + Title = UiText.Get("BreakpointsTitle"); + Width = 570; + SizeToContent = SizeToContent.Height; + CanResize = false; + ClassicDialog.Apply(this); + var body = new StackPanel { Margin = new Thickness(16), Spacing = 6 }; + void Field(string key, Control control) + { + body.Children.Add(new TextBlock { Text = UiText.Get(key) }); + control.HorizontalAlignment = HorizontalAlignment.Stretch; + AutomationProperties.SetName(control, UiText.Get(key)); + body.Children.Add(control); + } + Field("BreakpointKind", kind); + Field("BreakpointScript", script); + Field("BreakpointTarget", target); + Field("BreakpointLine", line); + Field("BreakpointAccess", access); + Field("BreakpointCondition", condition); + Field("BreakpointAction", action); + body.Children.Add(enabled); + body.Children.Add(error); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 8 }; + var ok = new Button { Name = "BreakpointOK", Content = UiText.Get("OK"), IsDefault = true }; + var cancel = new Button { Content = UiText.Get("Cancel"), IsCancel = true }; + ok.Click += (_, _) => + { + try + { + var value = ReadSpec(); + value.Validate(); + Close(value); + } + catch (Exception exception) when (exception is ArgumentException or RuntimeException) + { + error.Text = exception.Message; + } + }; + cancel.Click += (_, _) => Close(null); + buttons.Children.Add(ok); + buttons.Children.Add(cancel); + body.Children.Add(buttons); + Content = body; + kind.SelectionChanged += (_, _) => + { + line.IsEnabled = kind.SelectedItem is BreakpointKind.Line; + target.IsEnabled = kind.SelectedItem is BreakpointKind.Command or BreakpointKind.Variable; + access.IsEnabled = kind.SelectedItem is BreakpointKind.Variable; + }; + script.Text = spec.ScriptPath ?? ""; + target.Text = spec.Target; + line.Text = (spec.Line > 0 ? spec.Line : 1).ToString(); + condition.Text = spec.Condition; + action.Text = spec.Action; + enabled.IsChecked = spec.Enabled; + access.SelectedItem = spec.AccessMode; + kind.SelectedItem = spec.Kind; + Dialogs.RegisterNames(this); + } + + public BreakpointSpec ReadSpec() + { + var breakpointKind = kind.SelectedItem is BreakpointKind value ? value : throw new ArgumentException("Select a breakpoint kind."); + if (breakpointKind == BreakpointKind.Line && (!int.TryParse(line.Text, out var number) || number < 1)) + throw new ArgumentException("Enter a positive line number."); + return new(breakpointKind, string.IsNullOrWhiteSpace(script.Text) ? null : Path.GetFullPath(script.Text.Trim()), + target.Text?.Trim() ?? "", breakpointKind == BreakpointKind.Line ? int.Parse(line.Text!) : 0, + condition.Text ?? "", enabled.IsChecked == true, + access.SelectedItem is VariableAccessMode mode ? mode : VariableAccessMode.Write, action.Text ?? ""); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/ClassicDialog.cs b/src/Devolutions.Terminal.Ise/Iseberg/ClassicDialog.cs new file mode 100644 index 0000000..24bdf44 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/ClassicDialog.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml.Styling; + +namespace Iseberg; + +internal static class ClassicDialog +{ + public static void Apply(Window window) + { + DesktopTheme.ApplyWindow(window); + window.Classes.Add("options"); + window.Styles.Add(new StyleInclude(new Uri("avares://Iseberg/")) + { + Source = new Uri("avares://Iseberg/CommandDialogStyles.axaml") + }); + window.Icon = AppIcon.Create(); + window.FontSize = 12 * DesktopTheme.TextScale; + window.ShowInTaskbar = false; + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + window.UseLayoutRounding = true; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/ColorPreview.cs b/src/Devolutions.Terminal.Ise/Iseberg/ColorPreview.cs new file mode 100644 index 0000000..1ed0d71 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/ColorPreview.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace Iseberg; + +public sealed class ColorPreview : Control +{ + public static readonly StyledProperty ColorProperty = AvaloniaProperty.Register(nameof(Color)); + public Color? Color { get => GetValue(ColorProperty); set => SetValue(ColorProperty, value); } + static ColorPreview() => AffectsRender(ColorProperty); + public override void Render(DrawingContext context) + { + context.FillRectangle(Color is { } color ? new SolidColorBrush(color) : + DesktopTheme.HighContrast ? DesktopTheme.Brush("ControlBrush") : new SolidColorBrush(Avalonia.Media.Color.Parse("#EEEEEE")), + new Rect(Bounds.Size)); + if (Color is not null) return; + var pen = new Pen(DesktopTheme.Brush("WindowBrush"), 2); + for (var x = 0; x < Bounds.Width; x += 21) context.DrawLine(pen, new(x, 0), new(x, Bounds.Height)); + for (var y = 0; y < Bounds.Height; y += 28) context.DrawLine(pen, new(0, y), new(Bounds.Width, y)); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/CommandDialogStyles.axaml b/src/Devolutions.Terminal.Ise/Iseberg/CommandDialogStyles.axaml new file mode 100644 index 0000000..6707cd9 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/CommandDialogStyles.axaml @@ -0,0 +1,55 @@ + + + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg/CommandFormView.axaml b/src/Devolutions.Terminal.Ise/Iseberg/CommandFormView.axaml new file mode 100644 index 0000000..4ea97cb --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/CommandFormView.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg/CommandFormView.axaml.cs b/src/Devolutions.Terminal.Ise/Iseberg/CommandFormView.axaml.cs new file mode 100644 index 0000000..b1fbd70 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/CommandFormView.axaml.cs @@ -0,0 +1,283 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Layout; +using Avalonia.Media; +using Iseberg.Core; + +namespace Iseberg; + +public sealed partial class CommandFormView : UserControl +{ + public CommandForm? Form { get; private set; } + public CommandFormResult? Result { get; private set; } + public event Action? CommandChanged; + public bool Compact { get; init; } + + public CommandFormView() + { + InitializeComponent(); + ParameterSetTabs.ItemTemplate = new FuncDataTemplate((set, _) => + new TextBlock { Text = set?.Name == "__AllParameterSets" ? UiText.Get("DefaultParameterSet") : set?.Name }); + ParameterSetTabs.SelectionChanged += (_, _) => + { + if (ParameterSetTabs.SelectedItem is CommandParameterSetDescription set) + ParameterSetPicker.SelectedItem = set; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + ParameterSetTabs.ContainerFromIndex(ParameterSetTabs.SelectedIndex)?.BringIntoView()); + }; + ParameterSetPicker.SelectionChanged += (_, _) => + { + if (Form is null || ParameterSetPicker.SelectedItem is not CommandParameterSetDescription set) return; + Form.SelectSet(set.Name); + ParameterSetTabs.SelectedItem = set; + RenderParameters(); + }; + ShowMessage(UiText.Get("SelectCommand")); + } + + public void ShowMessage(string message) + { + Form = null; + Result = null; + CommandTitle.Text = message; + SetPanel.IsVisible = false; + ParameterSetPicker.ItemsSource = null; + Parameters.Children.Clear(); + CommonParameters.Children.Clear(); + CommonParameterExpander.IsVisible = false; + CommandPreview.Text = ""; + CommandValidation.Text = ""; + CommandChanged?.Invoke(); + } + + public void ShowCommand(CommandForm form) + { + Form = form; + CommandTitle.Text = form.Description.Name; + CommandTitle.IsVisible = !Compact; + SetPanel.IsVisible = true; + SetPanel.Children[0].IsVisible = ParameterSetPicker.IsVisible = !Compact; + ParameterSetTabs.IsVisible = Compact; + ParameterSetTabScroll.IsVisible = Compact; + ParameterFrame.BorderThickness = Compact ? new Thickness(1) : new Thickness(0); + ParameterFrame.BorderBrush = DesktopTheme.Brush("BorderBrush"); + Parameters.Margin = Compact ? new Thickness(8,4) : new Thickness(0); + PreviewExpander.IsVisible = !Compact; + ((Grid)Content!).RowSpacing = Compact ? 0 : 4; + CommonParameterExpander.Margin = Compact ? new Thickness(0,5,0,0) : new Thickness(0); + ParameterSetTabs.ItemsSource = form.Description.ParameterSets; + ParameterSetPicker.ItemsSource = form.Description.ParameterSets; + ParameterSetPicker.SelectedItem = form.SelectedSet; + RenderParameters(); + } + + public string GetCommand() + { + if (Result is not { IsValid: true } result) + throw new InvalidOperationException(CommandValidation.Text ?? UiText.Get("SelectCommand")); + return result.Script; + } + + private void RenderParameters() + { + if (Form is null) return; + Parameters.Children.Clear(); + CommonParameters.Children.Clear(); + foreach (var parameter in Form.SelectedSet.Parameters.Where(p => !p.IsCommon)) + Parameters.Children.Add(CreateParameter(parameter)); + var common = Form.SelectedSet.Parameters.Where(p => p.IsCommon).ToArray(); + if (common.Length > 0) + { + foreach (var parameter in common) CommonParameters.Children.Add(CreateParameter(parameter)); + } + CommonParameterExpander.IsVisible = common.Length > 0; + CommonParameterExpander.IsExpanded = common.Any(p => p.IsMandatory || Form.Value(p.Name).Included); + if (Form.SelectedSet.Parameters.Count == 0) + Parameters.Children.Add(new TextBlock { Text = UiText.Get("NoCommandParameters"), TextWrapping = TextWrapping.Wrap }); + UpdateCommand(); + } + + private Control CreateParameter(CommandParameterDescription parameter) + { + var value = Form!.Value(parameter.Name); + var panel = new StackPanel { Spacing = 4, Margin = new Thickness(4) }; + var inputPanel = new StackPanel { Spacing = 4 }; + var include = new CheckBox { Content = UiText.Get("IncludeParameter"), IsChecked = value.Included }; + AutomationProperties.SetName(include, parameter.Name + " - " + UiText.Get("IncludeParameter")); + panel.Children.Add(include); + if (!parameter.IsArray && parameter.Kind is (CommandParameterKind.Switch or CommandParameterKind.Boolean)) + { + var boolean = new ComboBox { ItemsSource = new[] { "$true", "$false" }, SelectedIndex = value.Boolean ? 0 : 1, + HorizontalAlignment = HorizontalAlignment.Stretch }; + AutomationProperties.SetName(boolean, parameter.Name); + boolean.SelectionChanged += (_, _) => + { + value.Boolean = boolean.SelectedIndex == 0; + include.IsChecked = true; + UpdateCommand(); + }; + inputPanel.Children.Add(boolean); + } + else + { + var input = new TextBox + { + Text = value.Text, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, + MinHeight = Compact ? 25 : parameter.IsArray ? 70 : 30, MaxHeight = 180 + }; + AutomationProperties.SetName(input, parameter.Name); + var expression = new CheckBox { Content = UiText.Get("PowerShellExpression"), IsChecked = value.IsExpression }; + AutomationProperties.SetName(expression, parameter.Name + " - " + UiText.Get("PowerShellExpression")); + Control? choice = null; + var updatingChoices = false; + var comparer = parameter.ChoicesIgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + if (parameter.Choices.Count > 0) + { + if (parameter.IsArray) + { + var picker = new ListBox + { + ItemsSource = parameter.Choices, SelectionMode = SelectionMode.Multiple, + MaxHeight = 150, MinHeight = 60 + }; + var selected = value.Text.Replace("\r\n", "\n").Split('\n'); + foreach (var entry in parameter.Choices.Where(entry => selected.Contains(entry, comparer))) + picker.SelectedItems?.Add(entry); + picker.SelectionChanged += (_, _) => + { + if (!updatingChoices) input.Text = string.Join("\n", picker.SelectedItems?.Cast() ?? []); + }; + choice = picker; + } + else + { + var picker = new ComboBox { ItemsSource = parameter.Choices, + SelectedItem = parameter.Choices.FirstOrDefault(entry => comparer.Equals(entry, value.Text)), + HorizontalAlignment = HorizontalAlignment.Stretch }; + picker.SelectionChanged += (_, _) => + { + if (!updatingChoices && picker.SelectedItem is string selected) input.Text = selected; + }; + choice = picker; + } + AutomationProperties.SetName(choice, parameter.Name); + inputPanel.Children.Add(choice); + input.IsVisible = value.IsExpression; + choice.IsVisible = !value.IsExpression; + } + inputPanel.Children.Add(input); + panel.Children.Add(expression); + input.TextChanged += (_, _) => + { + if (value.Text == (input.Text ?? "")) return; + value.Text = input.Text ?? ""; + include.IsChecked = !Compact || value.Text.Length > 0; + UpdateCommand(); + }; + expression.IsCheckedChanged += (_, _) => + { + value.IsExpression = expression.IsChecked == true; + if (choice is not null) + { + updatingChoices = true; + if (choice is ComboBox picker) + picker.SelectedItem = parameter.Choices.FirstOrDefault(entry => comparer.Equals(entry, value.Text)); + else if (choice is ListBox multiple) + { + multiple.SelectedItems?.Clear(); + var selected = value.Text.Replace("\r\n", "\n").Split('\n'); + foreach (var entry in parameter.Choices.Where(entry => selected.Contains(entry, comparer))) + multiple.SelectedItems?.Add(entry); + } + updatingChoices = false; + choice.IsVisible = !value.IsExpression; + input.IsVisible = value.IsExpression; + } + UpdateCommand(); + }; + panel.Children.Add(new TextBlock + { + Text = UiText.Get(parameter.IsArray ? "ArrayParameterHint" : "ParameterValueHint"), TextWrapping = TextWrapping.Wrap + }); + } + include.IsCheckedChanged += (_, _) => { value.Included = include.IsChecked == true; UpdateCommand(); }; + var details = parameter.TypeName; + if (parameter.Position is { } position) details += " | " + string.Format(UiText.Get("ParameterPosition"), position); + if (parameter.AcceptsPipelineInput) details += " | " + UiText.Get("PipelineInput"); + if (parameter.Aliases.Count > 0) details += "\n" + UiText.Get("ParameterAliases") + " " + string.Join(", ", parameter.Aliases); + if (parameter.Choices.Count > 0) details += "\n" + string.Join(", ", parameter.Choices); + if (parameter.HelpMessage.Length > 0) details += "\n" + parameter.HelpMessage; + panel.Children.Add(new TextBlock { Text = details, FontSize = 11, TextWrapping = TextWrapping.Wrap }); + if (Compact) + { + var row = new Grid { ColumnDefinitions = new("42*,58*"), RowDefinitions = new("Auto,Auto"), ColumnSpacing = 6 }; + Control label; + if (parameter.Kind == CommandParameterKind.Switch && !parameter.IsArray) + { + panel.Children.Insert(1, inputPanel); + var check = new CheckBox { Content = parameter.Name, IsChecked = value.Included && value.Boolean, + Margin = new Thickness(0,3) }; + AutomationProperties.SetName(check, parameter.Name); + check.IsCheckedChanged += (_, _) => + { + value.Included = check.IsChecked == true; + value.Boolean = true; + include.IsChecked = value.Included; + UpdateCommand(); + }; + label = check; + Grid.SetColumnSpan(check, 2); + } + else + { + label = new TextBlock { Text = parameter.Name + ":" + (parameter.IsMandatory ? " *" : ""), + VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; + Grid.SetColumn(inputPanel, 1); row.Children.Add(inputPanel); + } + row.Children.Add(label); + var advanced = new Expander + { + Header = UiText.Get("ParameterOptions"), Content = panel, IsVisible = false, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + var options = new MenuItem { Header = UiText.Get("ParameterOptions") }; + options.Click += (_, _) => + { + advanced.IsVisible = true; + advanced.IsExpanded = !advanced.IsExpanded; + foreach (var input in inputPanel.Children.OfType()) + input.Height = advanced.IsExpanded ? parameter.IsArray ? 70 : 50 : 25; + }; + row.ContextMenu = new ContextMenu { Items = { options } }; + ToolTip.SetTip(row, details + "\n" + UiText.Get("ParameterOptionsHint")); + Grid.SetRow(advanced, 1); Grid.SetColumnSpan(advanced, 2); row.Children.Add(advanced); + AutomationProperties.SetName(row, parameter.Name); + return row; + } + panel.Children.Insert(1, inputPanel); + var expander = new Expander + { + Header = parameter.Name + (parameter.IsMandatory ? " *" : ""), Content = panel, + IsExpanded = parameter.IsMandatory || value.Included, HorizontalAlignment = HorizontalAlignment.Stretch + }; + AutomationProperties.SetName(expander, parameter.Name + (parameter.IsMandatory ? " - " + UiText.Get("RequiredParameter") : "")); + return expander; + } + + private void UpdateCommand() + { + Result = Form?.Build(); + CommandPreview.Text = Result?.Script ?? ""; + CommandValidation.Text = Result is null ? "" : string.Join("\n", new[] + { + Result.MissingParameters.Count == 0 ? "" : string.Format(UiText.Get("MissingParameters"), string.Join(", ", Result.MissingParameters)), + Result.InvalidExpressions.Count == 0 ? "" : string.Format(UiText.Get("InvalidParameterExpressions"), string.Join(", ", Result.InvalidExpressions)), + Result.InvalidChoices.Count == 0 ? "" : string.Format(UiText.Get("InvalidParameterChoices"), string.Join(", ", Result.InvalidChoices)) + }.Where(text => text.Length > 0)); + CommandValidation.IsVisible = !Compact && !string.IsNullOrEmpty(CommandValidation.Text); + ToolTip.SetTip(this, CommandValidation.Text); + CommandChanged?.Invoke(); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/CommandHelpWindow.cs b/src/Devolutions.Terminal.Ise/Iseberg/CommandHelpWindow.cs new file mode 100644 index 0000000..ec5b077 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/CommandHelpWindow.cs @@ -0,0 +1,219 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; +using Iseberg.Core; + +namespace Iseberg; + +public sealed class CommandHelpWindow : Window +{ + private readonly CommandHelpDocument document; + private readonly Func apply; + private readonly AccessibleTextEditor viewer; + private readonly TextBox find; + private readonly Button previous; + private readonly Button next; + private readonly TextBlock status; + private readonly HelpHeadings headings = new(); + private IReadOnlyList matches = []; + private int matchIndex = -1; + private string baseline; + private bool closingApproved; + private bool saving; + public HelpViewSettings Preferences { get; private set; } + + public CommandHelpWindow(CommandHelpDocument document, HelpViewSettings settings, Func? apply = null) + { + this.document = document; + this.apply = apply ?? (_ => Task.CompletedTask); + Preferences = settings.Copy(); + Preferences.Normalize(); + baseline = JsonSerializer.Serialize(Preferences); + ClassicDialog.Apply(this); + Title = string.Format(UiText.Get("CommandHelpTitle"), document.Name); + Width = 580 * DesktopTheme.TextScale; + Height = 360 * DesktopTheme.TextScale; + MinWidth = 400; + MinHeight = 250; + find = new TextBox { Name = "HelpFind", Width = 150 }; + AutomationProperties.SetName(find, UiText.Get("HelpFind")); + previous = new Button { Name = "HelpPrevious", Content = UiText.Get("HelpPrevious"), MinWidth = 52, Height = 25 }; + next = new Button { Name = "HelpNext", Content = UiText.Get("HelpNext"), MinWidth = 52, Height = 25 }; + var settingsButton = new Button { Name = "HelpSettingsButton", Content = UiText.Get("HelpSettings"), Height = 25 }; + var header = new Grid { ColumnDefinitions = new("Auto,Auto,Auto,Auto,*,Auto"), ColumnSpacing = 8 }; + header.Children.Add(new TextBlock { Text = UiText.Get("HelpFind") + ":", VerticalAlignment = VerticalAlignment.Center }); + Grid.SetColumn(find, 1); header.Children.Add(find); + Grid.SetColumn(previous, 2); header.Children.Add(previous); + Grid.SetColumn(next, 3); header.Children.Add(next); + Grid.SetColumn(settingsButton, 5); header.Children.Add(settingsButton); + viewer = new AccessibleTextEditor + { + Name = "HelpContent", IsReadOnly = true, WordWrap = true, + FontFamily = new FontFamily("Lucida Console, Consolas, DejaVu Sans Mono, Menlo, monospace"), + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto + }; + viewer.Options.AllowScrollBelowDocument = false; + viewer.Options.HighlightCurrentLine = false; + viewer.Options.LineHeightFactor = 1; + viewer.TextArea.TextView.LineTransformers.Add(headings); + AutomationProperties.SetName(viewer, UiText.Get("HelpContent")); + status = new TextBlock { Name = "HelpSearchStatus", VerticalAlignment = VerticalAlignment.Center }; + AutomationProperties.SetLiveSetting(status, AutomationLiveSetting.Polite); + var zoomText = new TextBlock { Name = "HelpZoomText", Width = 40, VerticalAlignment = VerticalAlignment.Center }; + var zoom = new Slider { Name = "HelpZoom", Minimum = 20, Maximum = 400, Value = Preferences.Zoom, + Width = 100, SmallChange = 10, LargeChange = 20 }; + AutomationProperties.SetName(zoom, UiText.Get("HelpZoom")); + var footer = new Grid { ColumnDefinitions = new("*,Auto,Auto"), ColumnSpacing = 5 }; + footer.Children.Add(status); + Grid.SetColumn(zoomText, 1); footer.Children.Add(zoomText); + Grid.SetColumn(zoom, 2); footer.Children.Add(zoom); + void SetZoom() + { + Preferences.Zoom = zoom.Value; + viewer.FontSize = 12 * DesktopTheme.TextScale * Preferences.Zoom / 100; + zoomText.Text = $"{Preferences.Zoom:0}%"; + } + zoom.ValueChanged += (_, _) => SetZoom(); + SetZoom(); + find.TextChanged += (_, _) => RefreshMatches(); + previous.Click += (_, _) => FindNext(backwards: true); + next.Click += (_, _) => FindNext(backwards: false); + find.KeyDown += (_, e) => + { + if (e.Key != Key.Enter) return; + FindNext(e.KeyModifiers.HasFlag(KeyModifiers.Shift)); + e.Handled = true; + }; + AddHandler(KeyDownEvent, (_, e) => + { + if (e.Key == Key.F && e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + find.Focus(); find.SelectAll(); e.Handled = true; + } + else if (e.Key == Key.F3) { FindNext(e.KeyModifiers.HasFlag(KeyModifiers.Shift)); e.Handled = true; } + else if (e.Key == Key.Escape) { Close(); e.Handled = true; } + }, RoutingStrategies.Tunnel); + settingsButton.Click += async (_, _) => + { + var result = await new HelpSettingsWindow(Preferences).ShowDialog(this); + if (result is null) return; + try + { + await this.apply(result.Copy()); + Preferences = result.Copy(); + baseline = JsonSerializer.Serialize(Preferences); + Render(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException) + { + System.Diagnostics.Trace.TraceError("Help settings could not be saved: {0}", exception); + status.Text = UiText.Get("HelpSettingsSaveFailed") + ": " + exception.Message; + } + }; + var grid = new Grid { RowDefinitions = new("Auto,*,Auto"), Margin = new Thickness(6), RowSpacing = 5 }; + grid.Children.Add(header); + Grid.SetRow(viewer, 1); grid.Children.Add(viewer); + Grid.SetRow(footer, 2); grid.Children.Add(footer); + Content = grid; + Dialogs.RegisterNames(this); + DesktopTheme.Changed += ApplyAppearance; + Closed += (_, _) => DesktopTheme.Changed -= ApplyAppearance; + Closing += async (_, e) => + { + if (closingApproved || baseline == JsonSerializer.Serialize(Preferences)) return; + e.Cancel = true; + if (saving) return; + saving = true; + try + { + await this.apply(Preferences.Copy()); + closingApproved = true; + Close(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException) + { + System.Diagnostics.Trace.TraceError("Help settings could not be saved: {0}", exception); + status.Text = UiText.Get("HelpSettingsSaveFailed") + ": " + exception.Message; + } + finally { saving = false; } + }; + ApplyAppearance(); + Render(); + } + + private void ApplyAppearance() + { + viewer.Background = DesktopTheme.Brush("WindowBrush"); + viewer.Foreground = DesktopTheme.Brush("WindowTextBrush"); + viewer.FontSize = 12 * DesktopTheme.TextScale * Preferences.Zoom / 100; + viewer.TextArea.TextView.Redraw(); + } + + private void Render() + { + var text = new StringBuilder(); + headings.Offsets.Clear(); + foreach (var section in document.Sections.Where(section => Preferences.Sections.Contains(section.Kind))) + { + if (text.Length > 0) text.Append("\n\n"); + headings.Offsets.Add(text.Length); + text.Append(UiText.Get("Help" + section.Kind)).Append('\n').Append(section.Text); + } + viewer.Text = text.ToString(); + viewer.CaretOffset = 0; + viewer.ScrollToHome(); + RefreshMatches(); + } + + private void RefreshMatches() + { + matchIndex = -1; + viewer.TextArea.ClearSelection(); + try + { + matches = string.IsNullOrEmpty(find.Text) ? [] : ReplacementSearch.Find(viewer.Text, find.Text, "", + new(MatchCase: Preferences.MatchCase, WholeWord: Preferences.WholeWord)); + status.Text = matches.Count > 0 || string.IsNullOrEmpty(find.Text) ? "" : UiText.Get("NoMatch"); + } + catch (RegexMatchTimeoutException exception) + { + System.Diagnostics.Trace.TraceError("Help search timed out: {0}", exception); + matches = []; + status.Text = UiText.Get("HelpSearchTimedOut"); + } + previous.IsEnabled = next.IsEnabled = matches.Count > 0; + } + + private void FindNext(bool backwards) + { + if (matches.Count == 0) return; + matchIndex = matchIndex < 0 ? backwards ? matches.Count - 1 : 0 + : (matchIndex + (backwards ? matches.Count - 1 : 1)) % matches.Count; + var match = matches[matchIndex]; + viewer.Select(match.Start, match.Length); + viewer.CaretOffset = match.Start + match.Length; + viewer.ScrollTo(viewer.TextArea.Caret.Line, viewer.TextArea.Caret.Column); + status.Text = string.Format(UiText.Get("HelpMatchCount"), matchIndex + 1, matches.Count); + } + + private sealed class HelpHeadings : DocumentColorizingTransformer + { + public HashSet Offsets { get; } = []; + protected override void ColorizeLine(DocumentLine line) + { + if (Offsets.Contains(line.Offset) && line.Length > 0) + ChangeLinePart(line.Offset, line.EndOffset, element => element.TextRunProperties.SetTypeface( + new Typeface(element.TextRunProperties.Typeface.FontFamily, weight: FontWeight.Bold))); + } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/ConsoleBuffer.cs b/src/Devolutions.Terminal.Ise/Iseberg/ConsoleBuffer.cs new file mode 100644 index 0000000..24099c8 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/ConsoleBuffer.cs @@ -0,0 +1,181 @@ +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using Iseberg.Core; + +namespace Iseberg; + +public sealed class ConsoleBuffer : IReadOnlySectionProvider +{ + public sealed record Span(int Start, int End, OutputKind Kind, int CodeStart = 0, ScriptAnalysis? Analysis = null, + OutputStyle? Style = null, IReadOnlyList? PromptStyles = null); + public TextDocument Document { get; } = new(); + public List Spans { get; } = []; + public int TranscriptEnd { get; private set; } + public int InputStart { get; private set; } + public bool HasPrompt { get; private set; } + private string draft = ""; + private string prompt = ""; + private string rawPrompt = ""; + private AnsiOutputParser ansi = new(); + private bool warnedUnsupportedControl; + public IReadOnlyList PromptParts { get; private set; } = []; + private string? analyzedInput; + private ScriptAnalysis? inputAnalysis; + public ScriptAnalysis InputAnalysis + { + get + { + var input = Input; + if (inputAnalysis is null || analyzedInput != input) + { + analyzedInput = input; + inputAnalysis = EditorAnalysis.Analyze(input); + } + return inputAnalysis; + } + } + + public string Input + { + get => HasPrompt ? Document.GetText(InputStart, Document.TextLength - InputStart) : draft; + set + { + if (HasPrompt) Document.Replace(InputStart, Document.TextLength - InputStart, value); + else draft = value; + } + } + + public bool CanInsert(int offset) => HasPrompt && offset >= InputStart && offset <= Document.TextLength; + + public IEnumerable GetDeletableSegments(ISegment segment) + { + var start = Math.Max(InputStart, segment.Offset); + var end = Math.Min(Document.TextLength, segment.EndOffset); + if (HasPrompt && end > start) yield return new SimpleSegment(start, end - start); + } + + public void ShowPrompt(string value) + { + if (HasPrompt && rawPrompt == value) return; + rawPrompt = value; + var parser = new AnsiOutputParser(); + PromptParts = parser.Parse(new(value)); + parser.Complete(); + if (parser.UnsupportedControl && !warnedUnsupportedControl) Mutate(ReportUnsupportedControl); + value = string.Concat(PromptParts.Select(part => part.Text)); + var input = Input; + Mutate(() => + { + Document.Replace(TranscriptEnd, Document.TextLength - TranscriptEnd, value + input); + prompt = value; + InputStart = TranscriptEnd + value.Length; + HasPrompt = true; + }); + } + + public void HidePrompt() + { + if (!HasPrompt) return; + draft = Input; + Mutate(() => + { + Document.Remove(TranscriptEnd, Document.TextLength - TranscriptEnd); + HasPrompt = false; + InputStart = TranscriptEnd; + }); + } + + public bool CompleteOutput() + { + ansi.Complete(); + if (ansi.UnsupportedControl && !warnedUnsupportedControl) + { + Mutate(ReportUnsupportedControl); + return true; + } + return false; + } + + private void ReportUnsupportedControl() + { + warnedUnsupportedControl = true; + AppendEntry(new(Environment.NewLine + UiText.Get("UnsupportedTerminalControl") + Environment.NewLine, OutputKind.Warning)); + } + + public void Append(OutputEntry entry) => AppendBatch([entry]); + + public void AppendBatch(IEnumerable entries) + { + Mutate(() => + { + foreach (var entry in entries) + { + if (entry.Kind == OutputKind.Command) + { + ansi.Complete(); + var parser = new AnsiOutputParser(); + var parts = parser.Parse(new(entry.Text[..entry.CodeStart])); + parser.Complete(); + var prefix = string.Concat(parts.Select(part => part.Text)); + AppendEntry(entry with { Text = prefix + entry.Text[entry.CodeStart..], CodeStart = prefix.Length }, parts); + if (parser.UnsupportedControl && !warnedUnsupportedControl) ReportUnsupportedControl(); + } + else foreach (var part in ansi.Parse(entry)) AppendEntry(part); + if (ansi.UnsupportedControl && !warnedUnsupportedControl) + ReportUnsupportedControl(); + } + Trim(); + }); + } + + private void AppendEntry(OutputEntry entry, IReadOnlyList? promptStyles = null) + { + var start = TranscriptEnd; + Document.Insert(start, entry.Text); + TranscriptEnd += entry.Text.Length; + InputStart += entry.Text.Length; + if (entry.Kind != OutputKind.Command && Spans.Count > 0 && Spans[^1].Kind == entry.Kind && Spans[^1].End == start && Spans[^1].Style == entry.Style) + Spans[^1] = Spans[^1] with { End = TranscriptEnd }; + else + Spans.Add(new(start, TranscriptEnd, entry.Kind, start + entry.CodeStart, + entry.Kind == OutputKind.Command ? EditorAnalysis.Analyze(entry.Text[entry.CodeStart..]) : null, entry.Style, promptStyles)); + } + + private void Trim() + { + if (TranscriptEnd <= 500_000) return; + var line = Document.GetLineByOffset(TranscriptEnd - 400_000); + var removed = line.Offset > 0 ? line.Offset : TranscriptEnd - 400_000; + if (TranscriptEnd - removed > 500_000) removed = TranscriptEnd - 400_000; + Document.Remove(0, removed); + TranscriptEnd -= removed; + InputStart -= removed; + var retained = Spans.Where(s => s.End > removed).Select(s => s with + { + Start = Math.Max(0, s.Start - removed), End = s.End - removed, CodeStart = s.CodeStart - removed + }).ToArray(); + Spans.Clear(); + Spans.AddRange(retained); + } + + public void Clear() + { + var input = Input; + Mutate(() => + { + Document.Text = HasPrompt ? prompt + input : ""; + TranscriptEnd = 0; + InputStart = HasPrompt ? prompt.Length : 0; + Spans.Clear(); + ansi = new(); + warnedUnsupportedControl = false; + }); + } + + private void Mutate(Action action) + { + Document.BeginUpdate(); + try { action(); } + finally { Document.EndUpdate(); Document.UndoStack.ClearAll(); } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/DesktopTheme.cs b/src/Devolutions.Terminal.Ise/Iseberg/DesktopTheme.cs new file mode 100644 index 0000000..a3a33b9 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/DesktopTheme.cs @@ -0,0 +1,153 @@ +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.Styling; + +namespace Iseberg; + +public static class DesktopTheme +{ + private static readonly Dictionary resources = []; + private static bool started; + public static bool HighContrast { get; private set; } + public static double TextScale { get; private set; } = 1; + public static event Action? Changed; + + public static void Refresh(bool? highContrast = null) + { + var contrast = new HighContrastInfo { Size = (uint)Marshal.SizeOf() }; + HighContrast = highContrast ?? (OperatingSystem.IsWindows() && + SystemParametersInfo(0x0042, contrast.Size, ref contrast, 0) && (contrast.Flags & 1) != 0); + var metrics = new NonClientMetrics { Size = (uint)Marshal.SizeOf() }; + var hasMetrics = OperatingSystem.IsWindows() && SystemParametersInfo(0x0029, metrics.Size, ref metrics, 0); + var dpi = OperatingSystem.IsWindowsVersionAtLeast(10) ? GetDpiForSystem() : 96; + var fontSize = hasMetrics ? Math.Max(12, Math.Abs(metrics.MessageFont.Height) * 96.0 / dpi) : 12; + TextScale = fontSize / 12; + resources["DesktopFontFamily"] = new FontFamily(hasMetrics && !string.IsNullOrWhiteSpace(metrics.MessageFont.FaceName) + ? metrics.MessageFont.FaceName : "Segoe UI, Noto Sans, DejaVu Sans"); + resources["DesktopFontSize"] = fontSize; + void Brush(string name, int index, string fallback) + { + var rgb = OperatingSystem.IsWindows() ? GetSysColor(index) : 0; + resources[name] = new SolidColorBrush(OperatingSystem.IsWindows() + ? Color.FromRgb((byte)rgb, (byte)(rgb >> 8), (byte)(rgb >> 16)) : Color.Parse(fallback)); + } + Brush("WindowBrush", 5, "#FFFFFF"); + Brush("WindowTextBrush", 8, "#000000"); + Brush("ControlBrush", 15, "#F0F0F0"); + Brush("ControlTextBrush", 18, "#000000"); + Brush("BorderBrush", 16, "#A0A0A0"); + Brush("DisabledTextBrush", 17, "#808080"); + Brush("SelectionBrush", 13, "#0078D7"); + Brush("SelectionTextBrush", 14, "#FFFFFF"); + resources["HoverBrush"] = HighContrast ? resources["SelectionBrush"]! : new SolidColorBrush(Color.Parse("#DCEAF8")); + resources["InputBrush"] = HighContrast ? resources["WindowBrush"]! : new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + GradientStops = [new GradientStop(Color.Parse("#F0F0F0"), 0), new GradientStop(Color.Parse("#E5E5E5"), 1)] + }; + resources["DialogBrush"] = resources["WindowBrush"]!; + foreach (var (key, color) in new Dictionary + { + ["OptionsFrameBrush"] = "#ACACAC", ["OptionsTreeBrush"] = "#828790", + ["OptionsGroupBrush"] = "#808080", ["OptionsInputBorderBrush"] = "#ABADB3", + ["OptionsButtonBorderBrush"] = "#707070", ["OptionsDisabledBorderBrush"] = "#ADB2B5" + }) + resources[key] = HighContrast ? resources["BorderBrush"]! : new SolidColorBrush(Color.Parse(color)); + resources["OptionsButtonBrush"] = HighContrast ? resources["ControlBrush"]! : new SolidColorBrush(Color.Parse("#DDDDDD")); + resources["RedChannelBrush"] = HighContrast ? resources["WindowBrush"]! : new SolidColorBrush(Color.Parse("#F08080")); + resources["GreenChannelBrush"] = HighContrast ? resources["WindowBrush"]! : new SolidColorBrush(Color.Parse("#80B080")); + resources["BlueChannelBrush"] = HighContrast ? resources["WindowBrush"]! : new SolidColorBrush(Color.Parse("#8080F0")); + foreach (var key in new[] { "ThemeBackgroundBrush", "ThemeControlLowBrush", "ThemeControlMidBrush", "ThemeControlHighBrush", "ThemeControlVeryHighBrush" }) + resources[key] = resources["ControlBrush"]!; + resources["ThemeForegroundBrush"] = resources["ControlTextBrush"]!; + foreach (var key in new[] { "ThemeBorderLowBrush", "ThemeBorderMidBrush", "ThemeBorderHighBrush" }) + resources[key] = resources["BorderBrush"]!; + resources["ThemeAccentBrush"] = resources["SelectionBrush"]!; + resources["ThemeAccentForegroundBrush"] = resources["SelectionTextBrush"]!; + Changed?.Invoke(); + } + + public static IBrush Brush(string name) => (IBrush)resources[name]; + + internal static void ApplyResources(Control control) + { + foreach (var (key, value) in resources) control.Resources[key] = value; + if (HighContrast || control.ActualThemeVariant != ThemeVariant.Dark) return; + foreach (var (key, color) in new Dictionary + { + ["WindowBrush"] = "#1E1E1E", ["WindowTextBrush"] = "#F0F0F0", + ["ControlBrush"] = "#252526", ["ControlTextBrush"] = "#F0F0F0", + ["DialogBrush"] = "#1E1E1E", ["BorderBrush"] = "#555555", + ["DisabledTextBrush"] = "#999999", ["HoverBrush"] = "#3A3D41", + ["InputBrush"] = "#333333", ["ThemeBackgroundBrush"] = "#252526", + ["ThemeForegroundBrush"] = "#F0F0F0", + ["ThemeControlLowBrush"] = "#252526", ["ThemeControlMidBrush"] = "#333333", + ["ThemeControlHighBrush"] = "#444444", ["ThemeControlVeryHighBrush"] = "#555555", + ["ThemeBorderLowBrush"] = "#555555", ["ThemeBorderMidBrush"] = "#666666", + ["ThemeBorderHighBrush"] = "#777777", ["OptionsButtonBrush"] = "#333333" + }) + control.Resources[key] = new SolidColorBrush(Color.Parse(color)); + } + + internal static void ApplyWindow(Window window) + { + Start(); + if (window.Classes.Contains("iseberg")) return; + window.Classes.Add("iseberg"); + window.Styles.Add(new WorkbenchStyles()); + ApplyResources(window); + void RefreshResources() => ApplyResources(window); + Changed += RefreshResources; + window.ActualThemeVariantChanged += (_, _) => RefreshResources(); + window.Opened += (_, _) => + { + if (window.RequestedThemeVariant == ThemeVariant.Default && window.Owner is Window owner) + window.RequestedThemeVariant = owner.ActualThemeVariant; + }; + window.Closed += (_, _) => Changed -= RefreshResources; + } + + public static void Start() + { + if (started) return; + started = true; + Refresh(); + if (Application.Current?.PlatformSettings is { } platform) + platform.ColorValuesChanged += (_, _) => Dispatcher.UIThread.Post(() => Refresh()); + } + + [StructLayout(LayoutKind.Sequential)] + private struct HighContrastInfo { public uint Size; public uint Flags; public IntPtr Scheme; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct LogFont + { + public int Height, Width, Escapement, Orientation, Weight; + public byte Italic, Underline, StrikeOut, CharacterSet, OutPrecision, ClipPrecision, Quality, PitchAndFamily; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string? FaceName; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NonClientMetrics + { + public uint Size; + public int BorderWidth, ScrollWidth, ScrollHeight, CaptionWidth, CaptionHeight; + public LogFont CaptionFont; + public int SmallCaptionWidth, SmallCaptionHeight; + public LogFont SmallCaptionFont; + public int MenuWidth, MenuHeight; + public LogFont MenuFont, StatusFont, MessageFont; + public int PaddedBorderWidth; + } + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SystemParametersInfo(uint action, uint parameter, ref HighContrastInfo info, uint flags); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SystemParametersInfo(uint action, uint parameter, ref NonClientMetrics info, uint flags); + [DllImport("user32.dll")] + private static extern uint GetSysColor(int index); + [DllImport("user32.dll")] + private static extern uint GetDpiForSystem(); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/Dialogs.cs b/src/Devolutions.Terminal.Ise/Iseberg/Dialogs.cs new file mode 100644 index 0000000..147a85d --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/Dialogs.cs @@ -0,0 +1,106 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.LogicalTree; +using Avalonia.Styling; + +namespace Iseberg; + +public static class Dialogs +{ + internal static void RegisterNames(Window window) + { + DesktopTheme.ApplyWindow(window); + var scope = new NameScope(); + NameScope.SetNameScope(window, scope); + foreach (var control in window.GetLogicalDescendants().OfType()) + if (control.Name is { Length: > 0 } name) scope.Register(name, control); + } + + public static Task AskAsync(Window owner, string title, string message, string initial = "", bool secret = false) => + AskAsync(owner, owner.ActualThemeVariant, title, message, initial, secret); + + public static async Task AskAsync(Window owner, ThemeVariant theme, string title, string message, string initial = "", bool secret = false) + { + var input = new TextBox { Text = initial, PasswordChar = secret ? '*' : '\0', MinWidth = 330 }; + var window = Create(title, 460); + window.RequestedThemeVariant = theme; + var panel = Body(message); + panel.Children.Add(input); + var buttons = Buttons(); + var ok = new Button { Content = UiText.Get("OK"), IsDefault = true, MinWidth = 75 }; + var cancel = new Button { Content = UiText.Get("Cancel"), IsCancel = true, MinWidth = 75 }; + ok.Click += (_, _) => window.Close(input.Text ?? ""); + cancel.Click += (_, _) => window.Close(null); + buttons.Children.Add(ok); + buttons.Children.Add(cancel); + panel.Children.Add(buttons); + window.Content = panel; + window.Opened += (_, _) => { input.Focus(); input.SelectAll(); }; + return await window.ShowDialog(owner); + } + + public static Task ChooseAsync(Window owner, string title, string message, params string[] choices) => + ChooseAsync(owner, owner.ActualThemeVariant, title, message, choices); + + public static async Task ChooseAsync(Window owner, ThemeVariant theme, string title, string message, params string[] choices) + { + var window = Create(title, 460); + window.RequestedThemeVariant = theme; + var panel = Body(message); + var buttons = Buttons(); + foreach (var choice in choices) + { + var button = new Button { Content = choice, MinWidth = 75, IsCancel = choice == UiText.Get("Cancel") }; + button.Click += (_, _) => window.Close(choice); + buttons.Children.Add(button); + } + panel.Children.Add(buttons); + window.Content = panel; + return await window.ShowDialog(owner); + } + + public static Task ShowTextAsync(Window owner, string title, string text) => + ShowTextAsync(owner, owner.ActualThemeVariant, title, text); + + public static async Task ShowTextAsync(Window owner, ThemeVariant theme, string title, string text) + { + var window = Create(title, 760); + window.RequestedThemeVariant = theme; + window.Height = 560; + window.CanResize = true; + window.Content = new TextBox + { + Text = text, + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontFamily = new FontFamily("Consolas, DejaVu Sans Mono, Menlo, monospace"), + Margin = new Thickness(10) + }; + await window.ShowDialog(owner); + } + + private static Window Create(string title, double width) + { + var window = new Window + { + Title = title, + Width = width, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false + }; + DesktopTheme.ApplyWindow(window); + return window; + } + private static StackPanel Body(string message) + { + var panel = new StackPanel { Margin = new Thickness(16), Spacing = 12 }; + panel.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }); + return panel; + } + private static StackPanel Buttons() => new() { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 8 }; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/EditorRendering.cs b/src/Devolutions.Terminal.Ise/Iseberg/EditorRendering.cs new file mode 100644 index 0000000..ba997b4 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/EditorRendering.cs @@ -0,0 +1,266 @@ +using Avalonia; +using Avalonia.Media; +using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using Iseberg.Core; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Text.RegularExpressions; + +namespace Iseberg; + +public readonly record struct SyntaxColorSpan(int Start, int End, Color Color); + +public sealed class PowerShellColorizer : DocumentColorizingTransformer +{ + private static readonly Regex XmlTokens = new("""(?)|[\w:.-]+)|(?[\w:.-]+)\s*=\s*(?"[^"]*"|'[^']*')""", + RegexOptions.Singleline | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + private ScriptAnalysis? analysis; + private EditorTheme theme = new(); + private string pane = "Script"; + private IReadOnlyList? colorSpans; + public ScriptAnalysis? Analysis { get => analysis; set { analysis = value; colorSpans = null; } } + public EditorTheme Theme { get => theme; set { theme = value; colorSpans = null; } } + public string Pane { get => pane; set { pane = value; colorSpans = null; } } + protected override void ColorizeLine(DocumentLine line) + { + if (Analysis is null || DesktopTheme.HighContrast) return; + foreach (var span in colorSpans ??= GetColorSpans(Analysis, Theme, Pane)) + if (span.Start < line.EndOffset && span.End > line.Offset) + ChangeLinePart(Math.Max(line.Offset, span.Start), Math.Min(line.EndOffset, span.End), + element => element.TextRunProperties.SetForegroundBrush(new SolidColorBrush(span.Color))); + } + + /// Ordered overlays shared by the editor and rich copy; later spans take precedence. + public static IReadOnlyList GetColorSpans(ScriptAnalysis analysis, EditorTheme theme, string pane = "Script") + { + var spans = new List(); + if (analysis.IsXml) + { + foreach (var token in analysis.XmlTokens) + spans.Add(new(token.Start, token.End, Color.Parse(theme.Colors["Xml." + token.Kind]))); + return spans; + } + Token? previous = null; + foreach (var token in analysis.Tokens) + { + var color = ColorFor(token, previous, theme, pane); + previous = token; + if (color is SolidColorBrush brush) + spans.Add(new(token.Extent.StartOffset, token.Extent.EndOffset, brush.Color)); + if (token is StringExpandableToken { NestedTokens: { } nested }) + foreach (var variable in nested) + spans.Add(new(variable.Extent.StartOffset, variable.Extent.EndOffset, Color.Parse(theme.Colors[pane + ".Variable"]))); + if (token is StringToken && token.Text.Contains('<')) + foreach (Match match in XmlTokens.Matches(token.Text)) + foreach (var name in new[] { "Comment", "Tag", "Attribute", "Value" }) + { + var group = match.Groups[name]; + var start = token.Extent.StartOffset + group.Index; + var end = start + group.Length; + if (group.Success) + spans.Add(new(start, end, Color.Parse(theme.Colors["Xml." + name]))); + } + } + return spans; + } + + public static IBrush? ColorFor(Token token, Token? previous, EditorTheme theme, string pane) + { + IBrush Brush(string kind) => new SolidColorBrush(Color.Parse(theme.Colors[pane + "." + kind])); + if (previous?.Kind is TokenKind.Function or TokenKind.Filter) return Brush("Function"); + if (token.TokenFlags.HasFlag(TokenFlags.AttributeName)) return Brush("Attribute"); + if (token.Kind == TokenKind.Label) return Brush("Label"); + if (token.Kind == TokenKind.Comment) return Brush("Comment"); + if (token.Kind is TokenKind.StringLiteral or TokenKind.StringExpandable or TokenKind.HereStringLiteral or TokenKind.HereStringExpandable) return Brush("String"); + if (token.Kind is TokenKind.Variable or TokenKind.SplattedVariable) return Brush("Variable"); + if (token.Kind == TokenKind.Number) return Brush("Number"); + if (token.Kind == TokenKind.Parameter) return Brush("Parameter"); + if (token.TokenFlags.HasFlag(TokenFlags.Keyword)) return Brush("Keyword"); + if (token.TokenFlags.HasFlag(TokenFlags.CommandName)) return Brush("Command"); + if (token.TokenFlags.HasFlag(TokenFlags.TypeName)) return Brush("Type"); + if (token.TokenFlags.HasFlag(TokenFlags.MemberName)) return Brush("Member"); + if ((token.TokenFlags & (TokenFlags.BinaryOperator | TokenFlags.UnaryOperator | TokenFlags.AssignmentOperator)) != 0) return Brush("Operator"); + if (token.Kind is TokenKind.Identifier or TokenKind.Generic) return Brush("CommandArgument"); + return null; + } +} + +public sealed class ConsoleColorizer(Func session, Func theme) : DocumentColorizingTransformer +{ + protected override void ColorizeLine(DocumentLine line) + { + if (session() is not { } current || DesktopTheme.HighContrast) return; + foreach (var span in current.OutputSpans) + { + if (span.Start >= line.EndOffset) break; + if (span.End <= line.Offset) continue; + var key = span.Kind switch + { + OutputKind.Error => "Stream.Error", + OutputKind.Warning => "Stream.Warning", + OutputKind.Verbose => "Stream.Verbose", + OutputKind.Debug => "Stream.Debug", + _ => "Console.Foreground" + }; + ChangeLinePart(Math.Max(line.Offset, span.Start), Math.Min(line.EndOffset, span.End), + element => element.TextRunProperties.SetForegroundBrush(new SolidColorBrush(Color.Parse(theme().Colors[key])))); + ApplyStyle(Math.Max(line.Offset, span.Start), Math.Min(line.EndOffset, span.End), span.Style, theme().Colors[key]); + if (span.CodeStart > span.Start && span.PromptStyles is { } promptStyles) + ApplyPromptStyles(line, span.Start, promptStyles); + if (span.Analysis is null) continue; + Token? previous = null; + foreach (var token in span.Analysis.Tokens) + { + var start = span.CodeStart + token.Extent.StartOffset; + var end = span.CodeStart + token.Extent.EndOffset; + if (start >= line.EndOffset) break; + var brush = PowerShellColorizer.ColorFor(token, previous, theme(), "Console"); + previous = token; + if (brush is null || end <= line.Offset) continue; + ChangeLinePart(Math.Max(line.Offset, start), Math.Min(line.EndOffset, end), + element => element.TextRunProperties.SetForegroundBrush(brush)); + } + } + if (!current.Console.HasPrompt) return; + ApplyPromptStyles(line, current.Console.TranscriptEnd, current.Console.PromptParts); + if (line.EndOffset <= current.Console.InputStart) return; + var analysis = current.Console.InputAnalysis; + Token? prior = null; + foreach (var token in analysis.Tokens) + { + var start = current.Console.InputStart + token.Extent.StartOffset; + var end = current.Console.InputStart + token.Extent.EndOffset; + var brush = PowerShellColorizer.ColorFor(token, prior, theme(), "Console"); + prior = token; + if (brush is not null && start < line.EndOffset && end > line.Offset) + ChangeLinePart(Math.Max(line.Offset, start), Math.Min(line.EndOffset, end), + element => element.TextRunProperties.SetForegroundBrush(brush)); + } + } + + private void ApplyPromptStyles(DocumentLine line, int offset, IReadOnlyList parts) + { + foreach (var part in parts) + { + var end = offset + part.Text.Length; + if (offset < line.EndOffset && end > line.Offset) + ApplyStyle(Math.Max(line.Offset, offset), Math.Min(line.EndOffset, end), part.Style); + offset = end; + } + } + + private void ApplyStyle(int start, int end, OutputStyle? style, string? defaultForeground = null) + { + if (style is null || start >= end) return; + ChangeLinePart(start, end, element => + { + var fg = style.Foreground; + var bg = style.Background; + if (style.Inverse) + (fg, bg) = (bg ?? theme().Colors["Console.TextBackground"], fg ?? defaultForeground ?? theme().Colors["Console.Foreground"]); + if (fg is { } foreground) + element.TextRunProperties.SetForegroundBrush(new SolidColorBrush(Color.Parse(foreground))); + if (bg is { } background) + element.TextRunProperties.SetBackgroundBrush(new SolidColorBrush(Color.Parse(background))); + if (style.Bold) element.TextRunProperties.SetTypeface(new Typeface(element.TextRunProperties.Typeface.FontFamily, + element.TextRunProperties.Typeface.Style, FontWeight.Bold)); + if (style.Underline) element.TextRunProperties.SetTextDecorations(TextDecorations.Underline); + }); + } +} + +public sealed class ScriptAdornments(Func file, Func debug, Func theme) : IBackgroundRenderer +{ + private ScriptTab? cachedFile; + private DebugLocation? cachedLocation; + private ITextSourceVersion? cachedVersion; + private (int Start, int End)? pausedSpan; + + public KnownLayer Layer => KnownLayer.Background; + public void Draw(TextView textView, DrawingContext context) + { + if (file() is not { } current || !textView.VisualLinesValid || DesktopTheme.HighContrast) return; + var location = debug(); + if (cachedFile != current || cachedLocation != location || cachedVersion?.CompareAge(current.Document.Version) != 0) + { + cachedFile = current; + cachedLocation = location; + cachedVersion = current.Document.Version; + pausedSpan = PausedStatementSpan(current, location); + } + var breakpoints = current.LineBreakpoints.ToDictionary(spec => spec.Line); + foreach (var line in textView.VisualLines) + { + var number = line.FirstDocumentLine.LineNumber; + var isDebug = pausedSpan is null && BreakpointVisuals.IsPausedLine(current, location, number); + var isBreakpoint = breakpoints.TryGetValue(number, out var spec) && spec.Enabled; + if (!isDebug && !isBreakpoint) continue; + var y = line.VisualTop - textView.ScrollOffset.Y; + var marker = BreakpointVisuals.MarkerColor(theme(), isDebug); + context.DrawRectangle(new SolidColorBrush(Color.FromArgb(12, marker.R, marker.G, marker.B)), + null, new Rect(0, y, textView.Bounds.Width, line.Height)); + } + if (pausedSpan is { } span) + { + var marker = BreakpointVisuals.MarkerColor(theme(), paused: true); + foreach (var rectangle in BackgroundGeometryBuilder.GetRectsForSegment(textView, + new SimpleSegment(span.Start, span.End - span.Start))) + context.DrawRectangle(new SolidColorBrush(Color.FromArgb(65, marker.R, marker.G, marker.B)), null, rectangle); + } + } + + public static (int Start, int End)? PausedStatementSpan(ScriptTab current, DebugLocation? location) => + location is not null && !current.File.IsDirty && !EditorAnalysis.IsXmlDocument(current.File.Path) && + BreakpointVisuals.IsPausedLine(current, location, location.Line) + ? EditorAnalysis.StatementAtPosition(current.Document.Text, location.Line, location.Column) : null; +} + +public sealed class ScriptDiagnosticRenderer(Func analysis, Func theme) : IBackgroundRenderer +{ + public KnownLayer Layer => KnownLayer.Selection; + + public void Draw(TextView textView, DrawingContext context) + { + if (!textView.VisualLinesValid || analysis() is not { State: EditorAnalysisState.Available } result) return; + foreach (var diagnostic in result.Diagnostics) + { + if (diagnostic.Span.End > textView.Document.TextLength) continue; + var brush = DesktopTheme.HighContrast ? DesktopTheme.Brush("WindowTextBrush") : + new SolidColorBrush(Color.Parse(theme().Colors[diagnostic.Severity == EditorDiagnosticSeverity.Error + ? "Stream.Error" : "Script.Parameter"])); + foreach (var rectangle in BackgroundGeometryBuilder.GetRectsForSegment(textView, + new SimpleSegment(diagnostic.Span.Start, diagnostic.Span.Length))) + { + var width = Math.Max(4, rectangle.Width); + var geometry = new StreamGeometry(); + using (var drawing = geometry.Open()) + { + drawing.BeginFigure(new(rectangle.Left, rectangle.Bottom - 1), false); + for (var x = 2d; x < width; x += 2) + drawing.LineTo(new(rectangle.Left + x, rectangle.Bottom - (x % 4 == 0 ? 1 : 3))); + drawing.LineTo(new(rectangle.Left + width, rectangle.Bottom - 1)); + drawing.EndFigure(false); + } + context.DrawGeometry(null, new Pen(brush, 1), geometry); + } + } + } +} + +public sealed class PowerShellCompletion(CompletionResult result) : ICompletionData +{ + public IImage? Image => null; + public string Text => result.CompletionText; + public object Content => result.ListItemText; + public object Description => result.ToolTip; + public double Priority => 0; + public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) + { + if (textArea.ReadOnlySectionProvider.CanInsert(completionSegment.Offset) && + textArea.ReadOnlySectionProvider.GetDeletableSegments(completionSegment).Sum(segment => segment.Length) == completionSegment.Length) + textArea.Document.Replace(completionSegment, result.CompletionText); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/EncodingWindow.cs b/src/Devolutions.Terminal.Ise/Iseberg/EncodingWindow.cs new file mode 100644 index 0000000..3bd9713 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/EncodingWindow.cs @@ -0,0 +1,55 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Automation; +using Avalonia.Layout; +using Iseberg.Core; + +namespace Iseberg; + +public sealed record EncodingSelection(ScriptEncoding Encoding, bool Reload); + +public sealed class EncodingWindow : Window +{ + public EncodingWindow(ScriptEncoding current, bool canReload, bool openingFile = false) + { + Title = UiText.Get("EncodingTitle"); + Width = 480; + SizeToContent = SizeToContent.Height; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + ShowInTaskbar = false; + Icon = AppIcon.Create(); + var choices = ScriptEncoding.Choices.ToArray(); + var list = new ComboBox + { + Name = "FileEncoding", ItemsSource = choices.Select(choice => choice.DisplayName).ToArray(), + SelectedIndex = Array.FindIndex(choices, choice => choice.CodePage == current.CodePage && choice.EmitBom == current.EmitBom), + HorizontalAlignment = HorizontalAlignment.Stretch + }; + if (list.SelectedIndex < 0) + { + choices = choices.Append(current).ToArray(); + list.ItemsSource = choices.Select(choice => choice.DisplayName).ToArray(); + list.SelectedIndex = choices.Length - 1; + } + AutomationProperties.SetName(list, UiText.Get("EncodingTitle")); + var panel = new StackPanel { Margin = new Thickness(16), Spacing = 12 }; + panel.Children.Add(new TextBlock { Text = UiText.Get(openingFile ? "OpenEncodingHint" : "EncodingHint"), TextWrapping = Avalonia.Media.TextWrapping.Wrap }); + panel.Children.Add(list); + var buttons = new WrapPanel { HorizontalAlignment = HorizontalAlignment.Right }; + foreach (var key in openingFile ? new[] { "OpenWithEncoding", "Cancel" } : new[] { "ConvertEncoding", "ReloadEncoding", "Cancel" }) + { + var button = new Button + { + Name = key, Content = UiText.Get(key), Margin = new Thickness(3), + IsEnabled = key != "ReloadEncoding" || canReload, IsCancel = key == "Cancel" + }; + button.Click += (_, _) => Close(key == "Cancel" || list.SelectedIndex < 0 ? null : + new EncodingSelection(choices[list.SelectedIndex], key == "ReloadEncoding")); + buttons.Children.Add(button); + } + panel.Children.Add(buttons); + Content = panel; + Dialogs.RegisterNames(this); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/GlobalUsings.cs b/src/Devolutions.Terminal.Ise/Iseberg/GlobalUsings.cs new file mode 100644 index 0000000..f81877e --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Iseberg.Editor; +global using Avalonia.Input.Platform; diff --git a/src/Devolutions.Terminal.Ise/Iseberg/HelpSettingsWindow.cs b/src/Devolutions.Terminal.Ise/Iseberg/HelpSettingsWindow.cs new file mode 100644 index 0000000..57a8975 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/HelpSettingsWindow.cs @@ -0,0 +1,65 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Layout; +using Iseberg.Core; + +namespace Iseberg; + +public sealed class HelpSettingsWindow : Window +{ + public HelpSettingsWindow(HelpViewSettings settings) + { + ClassicDialog.Apply(this); + Title = UiText.Get("HelpSettings"); + Width = 220 * DesktopTheme.TextScale; + SizeToContent = SizeToContent.Height; + CanResize = false; + var draft = settings.Copy(); + var sections = new Grid { ColumnDefinitions = new("*,*"), RowDefinitions = new("Auto,Auto,Auto,Auto,Auto"), RowSpacing = 9 }; + var kinds = Enum.GetValues(); + foreach (var (kind, index) in kinds.Select((kind, index) => (kind, index))) + { + var check = new CheckBox { Name = "HelpSection" + kind, Content = UiText.Get("Help" + kind), + IsChecked = draft.Sections.Contains(kind) }; + check.IsCheckedChanged += (_, _) => + { + draft.Sections.Remove(kind); + if (check.IsChecked == true) draft.Sections.Add(kind); + }; + Grid.SetColumn(check, index / 5); Grid.SetRow(check, index % 5); sections.Children.Add(check); + } + var search = new StackPanel { Spacing = 9 }; + var matchCase = new CheckBox { Name = "HelpMatchCase", Content = UiText.Get("HelpMatchCase"), IsChecked = draft.MatchCase }; + var wholeWord = new CheckBox { Name = "HelpWholeWord", Content = UiText.Get("HelpWholeWord"), IsChecked = draft.WholeWord }; + matchCase.IsCheckedChanged += (_, _) => draft.MatchCase = matchCase.IsChecked == true; + wholeWord.IsCheckedChanged += (_, _) => draft.WholeWord = wholeWord.IsChecked == true; + search.Children.Add(matchCase); search.Children.Add(wholeWord); + var ok = new Button { Name = "AcceptHelpSettings", Content = UiText.Get("OK"), IsDefault = true, MinWidth = 45 }; + var cancel = new Button { Name = "CancelHelpSettings", Content = UiText.Get("Cancel"), IsCancel = true, MinWidth = 45 }; + ok.Click += (_, _) => Close(draft.Copy()); + cancel.Click += (_, _) => Close(null); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 5 }; + buttons.Children.Add(ok); buttons.Children.Add(cancel); + var panel = new StackPanel { Margin = new Thickness(6), Spacing = 12 }; + panel.Children.Add(Group(UiText.Get("HelpSections"), sections)); + panel.Children.Add(Group(UiText.Get("HelpSearchOptions"), search)); + panel.Children.Add(buttons); + Content = panel; + Dialogs.RegisterNames(this); + } + + private static Control Group(string title, Control content) + { + var grid = new Grid { RowDefinitions = new("Auto,*") }; + var frame = new Border { BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(2), + BorderBrush = DesktopTheme.Brush("BorderBrush"), Padding = new Thickness(14,16,8,14), + Margin = new Thickness(0,7,0,0), Child = content }; + Grid.SetRowSpan(frame, 2); grid.Children.Add(frame); + var label = new TextBlock { Text = title, Background = DesktopTheme.Brush("WindowBrush"), + Margin = new Thickness(6,0,0,0), Padding = new Thickness(2,0), HorizontalAlignment = HorizontalAlignment.Left }; + grid.Children.Add(label); + AutomationProperties.SetName(grid, title); + return grid; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/HostInputWindow.cs b/src/Devolutions.Terminal.Ise/Iseberg/HostInputWindow.cs new file mode 100644 index 0000000..1448f3f --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/HostInputWindow.cs @@ -0,0 +1,62 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Automation; +using Avalonia.Layout; +using Avalonia.Media; +using Iseberg.Core; + +namespace Iseberg; + +public sealed class HostInputWindow : Window +{ + public HostInputWindow(InputRequest request) + { + Title = request.Caption; + Width = 480; + SizeToContent = SizeToContent.Height; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + ShowInTaskbar = false; + var body = new StackPanel { Margin = new(16), Spacing = 12 }; + body.Children.Add(new TextBlock { Text = request.Message, TextWrapping = TextWrapping.Wrap }); + var input = new TextBox { Name = "HostInput", PasswordChar = request.Secret ? '*' : '\0' }; + AutomationProperties.SetName(input, request.Message); + var choices = new List(); + if (request.Choices.Count == 0) body.Children.Add(input); + else + { + var list = new StackPanel { Spacing = 6 }; + for (var i = 0; i < request.Choices.Count; i++) + { + ToggleButton choice = request.MultipleChoice ? new CheckBox() : new RadioButton { GroupName = "HostChoice" }; + choice.Name = "HostChoice" + i; + choice.Content = request.Choices[i].Label; + choice.IsChecked = request.DefaultChoices.Contains(i); + ToolTip.SetTip(choice, request.Choices[i].Help); + choices.Add(choice); + list.Children.Add(choice); + } + body.Children.Add(new ScrollViewer { Content = list, MaxHeight = 360 }); + } + var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 8 }; + var ok = new Button { Name = "HostInputSubmit", Content = UiText.Get("OK"), IsDefault = true }; + var cancel = new Button { Name = "HostInputCancel", Content = UiText.Get("Cancel"), IsCancel = true }; + void Update() => ok.IsEnabled = choices.Count == 0 || request.MultipleChoice || choices.Any(choice => choice.IsChecked == true); + foreach (var choice in choices) choice.IsCheckedChanged += (_, _) => Update(); + Update(); + ok.Click += (_, _) => Close(choices.Count == 0 ? input.Text ?? "" : + choices.Any(choice => choice.IsChecked == true) + ? string.Join(",", choices.Select((choice, i) => (choice, i)).Where(pair => pair.choice.IsChecked == true).Select(pair => pair.i)) + : "-"); + cancel.Click += (_, _) => Close(null); + buttons.Children.Add(ok); + buttons.Children.Add(cancel); + body.Children.Add(buttons); + Content = body; + Dialogs.RegisterNames(this); + Opened += (_, _) => + { + if (choices.Count == 0) input.Focus(); + else (choices.FirstOrDefault(choice => choice.IsChecked == true) ?? choices[0]).Focus(); + }; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/IWorkbenchConsole.cs b/src/Devolutions.Terminal.Ise/Iseberg/IWorkbenchConsole.cs new file mode 100644 index 0000000..460658c --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/IWorkbenchConsole.cs @@ -0,0 +1,34 @@ +using Avalonia.Controls; +using Iseberg.Core; + +namespace Iseberg; + +/// Host-owned console presentation and transport for the workbench's existing runspace. +public interface IWorkbenchConsole : IAsyncDisposable +{ + Control View { get; } + bool HasSelection { get; } + bool IsInputEnabled { get; } + Task StartAsync(WorkbenchControl workbench, SessionModel session); + void Write(OutputEntry entry); + void RefreshState(bool acceptsCommands, bool inputDisabled); + void Focus(); + void Clear(); + Task CopyAsync(); + Task PasteAsync(); + void SelectAll(); + Task CompleteAsync(bool backwards = false); + /// Sets the code font size in device-independent pixels, including zoom. + void SetFontSize(double size); + void ApplyAppearance(EditorTheme theme, string fontFamily); + bool HasPendingInput { get; } + bool CanUndo => false; + bool CanRedo => false; + bool CanCut => false; + string InputText => ""; + int CaretOffset => 0; + void Undo() => throw new NotSupportedException("Console undo is not supported."); + void Redo() => throw new NotSupportedException("Console redo is not supported."); + Task CutAsync() => throw new NotSupportedException("Console cut is not supported."); + void ApplyPreferences(UserSettings preferences) { } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg/Iseberg.csproj b/src/Devolutions.Terminal.Ise/Iseberg/Iseberg.csproj new file mode 100644 index 0000000..f87e496 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/Iseberg.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg/OptionsStyles.axaml b/src/Devolutions.Terminal.Ise/Iseberg/OptionsStyles.axaml new file mode 100644 index 0000000..357a369 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/OptionsStyles.axaml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg/OptionsWindow.axaml b/src/Devolutions.Terminal.Ise/Iseberg/OptionsWindow.axaml new file mode 100644 index 0000000..38e56db --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg/OptionsWindow.axaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +