diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..dfdb8b771ce --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c860ebe3a63..434b8a969a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ jobs: os: - ubuntu-latest - windows-latest + # EN: Compile and test custom features natively on macOS as well. + # 中文:客製功能亦須在 macOS 原生環境完成編譯與測試。 + - macos-latest steps: # The Windows runners have autocrlf enabled by default. - name: Disable git autocrlf @@ -46,3 +49,5 @@ jobs: run: cargo test --all-features --all-targets - name: Run clippy run: cargo clippy --workspace --all-features --all-targets -- --no-deps --deny warnings + - name: Build release binary + run: cargo build -p edit --release diff --git a/Cargo.lock b/Cargo.lock index 4afe7a208b9..af13a2de7b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ dependencies = [ "argh_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -251,11 +251,33 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "edit" version = "2.0.0" dependencies = [ "criterion", + "dirs", "libc", "lsh", "stdext", @@ -277,6 +299,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -354,7 +387,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] @@ -374,6 +407,15 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + [[package]] name = "log" version = "0.4.29" @@ -424,6 +466,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "page_size" version = "0.6.0" @@ -518,6 +566,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.3" @@ -598,7 +657,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -644,6 +703,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -697,6 +787,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -738,7 +834,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -813,7 +909,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -824,7 +920,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -892,7 +988,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/README.md b/README.md index b62a023e5d4..d6f40e585cd 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,138 @@ This editor pays homage to the classic [MS-DOS Editor](https://en.wikipedia.org/ ![Screenshot of Edit with the About dialog in the foreground](./assets/edit_hero_image.png) +## msedit-tzk 客製版本 / Customized Build + +### 中文 + +本 repository 是 `kisaraki/edit` 的 Windows/WSL 跨平台客製開發版本。原始版本為 `2.0.0`,tzk +版本為 `0.0.8`;程式的版本資訊固定分成兩列顯示。Microsoft 原始版權及 MIT License +均予以保留,客製修改作者為 `KOSMOS, Tzushih.K`,修改版權為 +`Copyright (c) 2026 KOSMOS, Tzushih.K.`。 + +客製功能: + +- 提供 `DEFAULT`、`MSPWB`、`CIA`、`MM14`、`XT/AT Style` 五種顯示主題。Windows 設定儲存於 + `%APPDATA%\TZK\Edit\settings.json`;macOS 與 Linux 則使用平台標準設定目錄下的 + `TZK/Edit/settings.json`。檔案固定使用 LF,避免 CRLF 造成設定解析問題。 +- 選單列使用白色背景,狀態列使用綠色背景。 +- 「搜尋」與「取代」改用中央對話框,不再占用編輯區上方列;三個搜尋選項分列顯示,避免 + 長翻譯越過邊框。核取方塊採用跨平台固定欄寬的 `[ ]`/`[x]` 標記,確保 Windows、macOS + 與 Linux 終端機均能完整顯示外框;並保留 F3、Enter、Ctrl+Alt+Enter、Esc,以及搜尋、取代、 + 全部取代與關閉按鈕。 +- 「開啟舊檔」對話框分為上下兩欄;上欄維持目錄及檔案選擇,下欄顯示最近五次成功開啟 + 的檔案名稱與絕對路徑。最近檔案亦保存於設定檔中,最新項目優先且不重複。 +- 開啟檔案及未命名文件第一次存檔時,由跨平台目錄抽象取得各作業系統的桌面路徑; + Windows 仍支援移轉至 OneDrive 的桌面。 +- Windows 部署後會列入 `.txt`、`.md`、`.log` 的「開啟方式」候選清單,但不會變更使用者 + 原有的預設程式。 +- 「檢視 → 導覽視窗」只在 `.md` 文件啟用;其他格式保留選項但反灰停用。導覽視窗只解析 + `#` 至 `######` 六層 Markdown 標題(允許標題前方縮排,不解析星號及第七層以後), + 支援 `+`/`-` 折疊、點選跳轉、淺藍色目前位置及一般文字區域 `[......]`。視窗下緣顯示 + `ESC取消`。 +- 「編輯 → 邊界對齊」接受正整數 `n`,預設為 `80`。超過第 `n` 個字元的內容會移到新行, + 與下一原始行直接合併後繼續套用相同邊界規則直到檔尾;LF/CRLF 格式會予以保留。 + +未來的標準建置部署入口為 [build-deploy-all.ps1](./build-deploy-all.ps1)。它會開啟可見的 +PowerShell 7.6.4 終端機,依序處理 Windows 原生建置、WSL/Ubuntu 原生建置、Windows UAC +部署及 WSL sudo 部署。UAC 或 sudo 出現前會先顯示中英提醒,兩個平台的 Cargo、測試、版本 +及部署過程都不會在背景隱藏: + +```powershell +Set-Location D:\CodexWorkspace\msedit +& .\build-deploy-all.ps1 +``` + +Windows 正式執行檔保留於 `target\release\edit.exe` 並部署至 +`C:\Program Files\msedit-tzk`;WSL 正式執行檔保留於 +`/mnt/wsl-mount/msedit-tzk-target/release/edit`,並部署至 +`/mnt/wsl-mount/msedit-tzk-deploy`。若只需單一平台,可分別執行: + +```text +Windows:build-local.ps1、deploy-local.ps1 +WSL: build-wsl.sh、deploy-wsl.sh +``` + +完整的本機開發、檔案關聯及部署驗證規則請參閱 [setuplocal.md](./setuplocal.md)。 + +### English + +This repository is the customized Windows/WSL cross-platform build of `kisaraki/edit`. The upstream version is +`2.0.0`, and the tzk version is `0.0.8`; version information is always shown on two separate +lines. The original Microsoft copyright and MIT License are retained. The customized-build +authors are `KOSMOS, Tzushih.K`, with modification copyright +`Copyright (c) 2026 KOSMOS, Tzushih.K.` + +Customized features: + +- Provides five display themes: `DEFAULT`, `MSPWB`, `CIA`, `MM14`, and `XT/AT Style`. Windows stores + settings in `%APPDATA%\TZK\Edit\settings.json`; macOS and Linux use `TZK/Edit/settings.json` + beneath the platform-standard configuration directory. Files always use LF endings. +- Uses a white menu-bar background and a green status-bar background. +- Presents Find and Replace in centered dialogs instead of rows above the editor. The three search + options use separate rows so long translations cannot cross the border. Their checkboxes use the + fixed-width, cross-platform `[ ]`/`[x]` markers so Windows, macOS, and Linux terminals preserve the + complete dialog frame. F3, Enter, Ctrl+Alt+Enter, Escape, and explicit Find, Replace, Replace All, + and Close buttons remain available. +- Splits the Open dialog into two panes. The upper pane keeps the normal directory/file picker; + the bordered lower pane lists the five most recently opened files as one-click absolute-path + buttons. The settings file keeps these paths newest-first and without duplicates. +- Starts Open and the first Save As for an untitled document at the Desktop returned by a portable + directory abstraction on Windows, macOS, and Linux, including a Windows Desktop redirected to + OneDrive. +- Registers the application as an Open With candidate for `.txt`, `.md`, and `.log` without + changing the user's existing default application. +- Enables `View → Navigation Pane` only for `.md` documents and keeps it visibly disabled for other + formats. The pane parses Markdown headings from `#` through `######` (leading indentation is + accepted; asterisks and level seven or deeper are ignored), supports `+`/`-` folding, click-to-jump, + a light-blue current item, `[......]` content summaries, and an `ESC Cancel` footer. +- Adds `Edit → Boundary Alignment`, which accepts a positive integer `n` (default `80`). Text after + column `n` moves to a new line, joins the following original line, and is repeatedly normalized to + the same boundary through end-of-file while preserving LF or CRLF. + +The standard build-and-deploy entry point is [build-deploy-all.ps1](./build-deploy-all.ps1). It opens +a visible PowerShell 7.6.4 terminal and sequentially runs the native Windows build, native WSL/Ubuntu +build, Windows UAC deployment, and WSL sudo deployment. Bilingual notices appear before UAC or sudo, +and Cargo, tests, versions, and deployment output from both platforms remain visible: + +```powershell +Set-Location D:\CodexWorkspace\msedit +& .\build-deploy-all.ps1 +``` + +The Windows production executable remains at `target\release\edit.exe` and is deployed to +`C:\Program Files\msedit-tzk`. The WSL executable remains at +`/mnt/wsl-mount/msedit-tzk-target/release/edit` and is deployed under +`/mnt/wsl-mount/msedit-tzk-deploy`. For a single platform, use the corresponding scripts: + +```text +Windows: build-local.ps1 and deploy-local.ps1 +WSL: build-wsl.sh and deploy-wsl.sh +``` + +See [setuplocal.md](./setuplocal.md) for the complete local-development, file-association, deployment, +and verification rules. + +## 授權與上游貢獻 / License and Upstream Contributions + +`kisaraki/edit` 及本客製版本以 [MIT License](./LICENSE) 發布。Microsoft 原始程式碼的版權 +聲明繼續保留;`KOSMOS, Tzushih.K` 對其客製修改保留修改版權,並以相同 MIT License +授權。若內容含有第三方材料,仍須保留該材料原有的版權與授權聲明。 + +由 `kisaraki/edit` 提交至 `microsoft/edit` 的本客製修改,是作者本人獨立創作,不代表任何 +組織或公司,也不是在受僱工作中完成。作者同意依 Microsoft Contributor License Agreement +及上游專案適用的 MIT License 辦理貢獻授權。 + +`kisaraki/edit` and this customized build are distributed under the [MIT License](./LICENSE). +Microsoft's original copyright notice remains intact. `KOSMOS, Tzushih.K` retains modification +copyright in the customized changes and licenses those changes under the same MIT License. Any +third-party material must retain its existing copyright and license notices. + +The customized changes submitted from `kisaraki/edit` to `microsoft/edit` are the author's +independent personal work, do not represent an organization or company, and were not created in +the course of employment. The author agrees to license contributions under the Microsoft +Contributor License Agreement and the upstream project's applicable MIT License. + ## Installation [![Packaging status](https://repology.org/badge/vertical-allrepos/microsoft-edit.svg?exclude_unsupported=1)](https://repology.org/project/microsoft-edit/versions) diff --git a/assets/manpage/edit.1 b/assets/manpage/edit.1 index 90a9789f5ad..27f9bdab816 100644 --- a/assets/manpage/edit.1 +++ b/assets/manpage/edit.1 @@ -1,4 +1,4 @@ -.TH EDIT 1 "version 2.0.0" +.TH EDIT 1 "original version 2.0.0; tzk version 0.0.8" .SH NAME edit \- a simple text editor .SH SYNOPSIS @@ -26,6 +26,8 @@ Print the version number. .SH COPYRIGHT Copyright \(co Microsoft Corporation. .br +Modifications Copyright \(co 2026 KOSMOS, Tzushih.K. +.br Licensed under the MIT License. .SH SEE ALSO .UR https://github.com/microsoft/edit diff --git a/assets/snapcraft.yaml b/assets/snapcraft.yaml index 34c93d861de..003843ef7ce 100644 --- a/assets/snapcraft.yaml +++ b/assets/snapcraft.yaml @@ -1,6 +1,6 @@ name: msedit base: core24 -version: "2.0.0" +version: "2.0.0-tzk.0.0.8" summary: Edit is an MS-DOS inspired text editor from Microsoft description: | Edit pays homage to the classic MS-DOS Editor, but with a modern interface and input controls similar to VS Code. Learn more at https://github.com/microsoft/edit diff --git a/build-deploy-all.ps1 b/build-deploy-all.ps1 new file mode 100644 index 00000000000..1a4571d00fc --- /dev/null +++ b/build-deploy-all.ps1 @@ -0,0 +1,155 @@ +# Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [string]$Distro = 'Ubuntu-26.04', + [switch]$SkipTests, + [ValidateRange(0, 30)] + [int]$DisplaySeconds = 8, + [switch]$Child +) + +$ErrorActionPreference = 'Stop' +$RepoRoot = $PSScriptRoot +$RequiredPowerShellVersion = [version]'7.6.4' +$Pwsh = (Get-Command 'pwsh.exe' -ErrorAction Stop).Source +$DetectedPowerShellVersion = [version](& $Pwsh -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion.ToString()') +if ($DetectedPowerShellVersion -lt $RequiredPowerShellVersion) { + throw "PowerShell $RequiredPowerShellVersion or later is required; found $DetectedPowerShellVersion." +} + +# EN: The public entry opens one visible terminal for the complete Windows and WSL workflow. +# 中文:公開入口會開啟一個可見終端機,完整呈現 Windows 與 WSL 的處理過程。 +if (-not $Child) { + $arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -Child -Distro `"$Distro`" -DisplaySeconds $DisplaySeconds" + if ($SkipTests) { + $arguments += ' -SkipTests' + } + + $process = Start-Process -FilePath $Pwsh -ArgumentList $arguments -PassThru + $process.WaitForExit() + exit $process.ExitCode +} + +$Host.UI.RawUI.WindowTitle = 'msedit-tzk Windows + WSL build and deployment' +$TranscriptPath = Join-Path $RepoRoot 'target\build\build-deploy-all-transcript.txt' +$ResultPath = Join-Path $RepoRoot 'target\build\build-deploy-all-result.json' +$transcriptStarted = $false +$result = [ordered]@{ + success = $false + message = 'Dual-platform build and deployment did not complete.' + timestamp = (Get-Date).ToString('o') + distro = $Distro +} +$exitCode = 1 + +try { + New-Item -ItemType Directory -Path (Split-Path -Parent $TranscriptPath) -Force | Out-Null + Start-Transcript -LiteralPath $TranscriptPath -Force | Out-Null + $transcriptStarted = $true + Set-Location -LiteralPath $RepoRoot + + Write-Host '=== Windows + WSL dual-platform workflow ===' -ForegroundColor Cyan + Write-Host "PowerShell : $($PSVersionTable.PSVersion)" + Write-Host "Repository : $RepoRoot" + Write-Host "WSL distro : $Distro" + + # EN: Run the Windows compiler in the same visible console so Cargo output remains observable. + # 中文:Windows 編譯器共用目前的可見主控台,確保 Cargo 輸出可直接查看。 + $windowsBuildArguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $RepoRoot 'build-local.ps1')`" -Child -DisplaySeconds 0" + if ($SkipTests) { + $windowsBuildArguments += ' -SkipTests' + } + $windowsBuild = Start-Process -FilePath $Pwsh -ArgumentList $windowsBuildArguments -NoNewWindow -PassThru -Wait + if ($windowsBuild.ExitCode -ne 0) { + throw "Windows build failed with exit code $($windowsBuild.ExitCode)." + } + + # EN: Forward slashes prevent WSL argument parsing from consuming Windows backslashes. + # 中文:先改用正斜線,避免 WSL 參數解析時吃掉 Windows 路徑中的反斜線。 + $portableRepoRoot = $RepoRoot.Replace('\', '/') + $wslRepoOutput = @(& wsl.exe -d $Distro -- wslpath -a -u $portableRepoRoot) + if ($LASTEXITCODE -ne 0 -or $wslRepoOutput.Count -eq 0) { + throw "Unable to resolve the repository path in WSL: $RepoRoot" + } + $wslRepoRoot = $wslRepoOutput[0].Trim() + if (-not $wslRepoRoot) { + throw "WSL returned an empty repository path for: $RepoRoot" + } + + # EN: WSL output stays attached to this visible terminal; Linux artifacts go to /mnt/wsl-mount. + # 中文:WSL 輸出持續顯示於目前終端機;Linux 產物寫入 /mnt/wsl-mount。 + $wslBuildArguments = @('-d', $Distro, '--') + if ($SkipTests) { + $wslBuildArguments += @('env', 'MSEDIT_TZK_SKIP_TESTS=1') + } + $wslBuildArguments += @('bash', "$wslRepoRoot/build-wsl.sh") + & wsl.exe @wslBuildArguments + if ($LASTEXITCODE -ne 0) { + throw "WSL build failed with exit code $LASTEXITCODE." + } + + # EN: Stop before UAC if an installed editor instance would lock the deployment target. + # 中文:若已安裝的編輯器仍在執行並鎖住部署目標,則在 UAC 前停止並提醒使用者關閉。 + $installedEdit = Join-Path $env:ProgramFiles 'msedit-tzk\edit.exe' + $lockingEditors = @( + Get-Process -Name 'edit' -ErrorAction SilentlyContinue | Where-Object { + try { + $_.Path -eq $installedEdit + } + catch { + $false + } + } + ) + if ($lockingEditors.Count -gt 0) { + $lockingIds = ($lockingEditors.Id -join ', ') + throw "Close the installed msedit-tzk window before deployment (PID: $lockingIds)." + } + + # EN: Remind the user before Windows displays its elevation consent dialog. + # 中文:Windows 顯示權限提升確認視窗前,先提醒使用者。 + Write-Warning 'UAC reminder / UAC 提醒:接下來會出現 Windows 權限提升視窗,用於部署至 Program Files。' + Start-Sleep -Seconds 2 + $deployScript = (Resolve-Path (Join-Path $RepoRoot 'deploy-local.ps1')).Path + $deployArguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$deployScript`"" + $windowsDeploy = Start-Process -FilePath $Pwsh -ArgumentList $deployArguments -Verb RunAs -PassThru -Wait + if ($windowsDeploy.ExitCode -ne 0) { + throw "Windows deployment failed with exit code $($windowsDeploy.ExitCode)." + } + + # EN: Remind the user before sudo may request the Ubuntu password in this terminal. + # 中文:sudo 可能於目前終端機要求 Ubuntu 密碼前,先提醒使用者。 + Write-Warning 'sudo reminder / sudo 提醒:接下來 WSL 可能要求 Ubuntu 密碼,用於更新 /usr/local/bin。' + Start-Sleep -Seconds 2 + & wsl.exe -d $Distro -- bash "$wslRepoRoot/deploy-wsl.sh" + if ($LASTEXITCODE -ne 0) { + throw "WSL deployment failed with exit code $LASTEXITCODE." + } + + $result.success = $true + $result.message = 'Windows and WSL builds and deployments completed successfully.' + $result.windowsResult = Join-Path $RepoRoot 'target\deploy\deployment-result.json' + $result.wslBuildResult = '/mnt/wsl-mount/msedit-tzk-build/build-result.txt' + $result.wslDeployRoot = '/mnt/wsl-mount/msedit-tzk-deploy' + Write-Host '=== Dual-platform workflow completed successfully ===' -ForegroundColor Green + $exitCode = 0 +} +catch { + $result.message = $_.Exception.Message + Write-Host "DUAL-PLATFORM WORKFLOW FAILED: $($result.message)" -ForegroundColor Red +} +finally { + $result.timestamp = (Get-Date).ToString('o') + $result | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $ResultPath -Encoding utf8 + if ($transcriptStarted) { + Stop-Transcript | Out-Null + } + if ($DisplaySeconds -gt 0) { + Write-Host "Terminal closes in $DisplaySeconds seconds..." + Start-Sleep -Seconds $DisplaySeconds + } +} + +exit $exitCode diff --git a/build-local.ps1 b/build-local.ps1 new file mode 100644 index 00000000000..ca4f5a924ac --- /dev/null +++ b/build-local.ps1 @@ -0,0 +1,211 @@ +# Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [switch]$SkipTests, + [ValidateRange(0, 30)] + [int]$DisplaySeconds = 5, + [switch]$Child +) + +$ErrorActionPreference = 'Stop' +$RepoRoot = $PSScriptRoot +$RequiredPowerShellVersion = [version]'7.6.4' +$Pwsh = (Get-Command 'pwsh.exe' -ErrorAction Stop).Source +$DetectedPowerShellVersion = [version](& $Pwsh -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion.ToString()') +if ($DetectedPowerShellVersion -lt $RequiredPowerShellVersion) { + throw "PowerShell $RequiredPowerShellVersion or later is required; found $DetectedPowerShellVersion." +} + +# EN: The public entry always opens a visible PowerShell 7.6.4 compiler window. +# 中文:公開入口固定開啟可見的 PowerShell 7.6.4 編譯視窗;-Child 僅用於避免重複啟動。 +if (-not $Child) { + $arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -Child -DisplaySeconds $DisplaySeconds" + if ($SkipTests) { + $arguments += ' -SkipTests' + } + + $process = Start-Process -FilePath $Pwsh -ArgumentList $arguments -PassThru + $process.WaitForExit() + # EN: Preserve the child exit code so callers receive the compiler result. + # 中文:保留子程序結束碼,讓呼叫端取得正確的編譯結果。 + $childExitCode = $process.ExitCode + exit $childExitCode +} + +$Cargo = Join-Path $env:USERPROFILE '.cargo\bin\cargo.exe' +$Rustup = Join-Path $env:USERPROFILE '.cargo\bin\rustup.exe' +$CrossCheckTargets = @('x86_64-unknown-linux-gnu', 'x86_64-apple-darwin') +$ReleaseExe = Join-Path $RepoRoot 'target\release\edit.exe' +$StageDir = Join-Path $RepoRoot 'target\deploy\msedit-tzk' +$ResultPath = Join-Path $RepoRoot 'target\build\build-result.json' +$TranscriptPath = Join-Path $RepoRoot 'target\build\build-transcript.txt' +$result = [ordered]@{ + success = $false + message = 'Build did not complete.' + timestamp = (Get-Date).ToString('o') + testsSkipped = [bool]$SkipTests + powerShellVersion = $DetectedPowerShellVersion.ToString() +} +$exitCode = 1 +$transcriptStarted = $false + +function Get-Sha256 { + # EN: Use .NET hashing so the build remains valid even when Get-FileHash is shadowed. + # 中文:使用 .NET 計算雜湊,避免 Get-FileHash 被同名命令遮蔽時影響建置驗證。 + param([Parameter(Mandatory)][string]$Path) + + $stream = [IO.File]::OpenRead($Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '') + } + finally { + $sha.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Get-VersionInfo { + # EN: The executable contract is exactly two independently validated version lines. + # 中文:執行檔介面固定為兩列,且原始版本與 tzk 版本必須分別驗證。 + param([Parameter(Mandatory)][string]$Path) + + $lines = @(& $Path --version) + if ($LASTEXITCODE -ne 0 -or $lines.Count -ne 2) { + throw "Unexpected version declaration from $Path" + } + if ($lines[0] -notmatch '^edit original version: (\d+\.\d+\.\d+)$') { + throw "Unexpected original version declaration: $($lines[0])" + } + $originalVersion = $Matches[1] + if ($lines[1] -notmatch '^tzk version: (\d+\.\d+\.\d+)$') { + throw "Unexpected tzk version declaration: $($lines[1])" + } + + [pscustomobject]@{ + OriginalVersion = $originalVersion + TzkVersion = $Matches[1] + Lines = $lines + } +} + +try { + if (-not (Test-Path -LiteralPath $Cargo -PathType Leaf)) { + throw "Cargo executable not found: $Cargo" + } + if (-not (Test-Path -LiteralPath $Rustup -PathType Leaf)) { + throw "rustup executable not found: $Rustup" + } + + if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion -lt $RequiredPowerShellVersion) { + throw "The compiler child must run in PowerShell $RequiredPowerShellVersion or later." + } + + $Host.UI.RawUI.WindowTitle = 'msedit-tzk Rust release build - PowerShell 7.6.4' + Set-Location -LiteralPath $RepoRoot + # EN: Preserve the visible compiler output so failed tests can be diagnosed after the window closes. + # 中文:保留可見編譯視窗的輸出,讓視窗關閉後仍可診斷測試失敗原因。 + New-Item -ItemType Directory -Path (Split-Path -Parent $TranscriptPath) -Force | Out-Null + Start-Transcript -LiteralPath $TranscriptPath -Force | Out-Null + $transcriptStarted = $true + Write-Host '=== msedit-tzk visible Rust build ===' -ForegroundColor Cyan + Write-Host "Repository : $RepoRoot" + Write-Host "Cargo : $Cargo" + Write-Host "PowerShell : $($PSVersionTable.PSVersion)" + Write-Host "Output : $ReleaseExe" + + & $Cargo fmt --all --check + if ($LASTEXITCODE -ne 0) { + throw 'Cargo format check failed.' + } + + if (-not $SkipTests) { + & $Cargo test --workspace + if ($LASTEXITCODE -ne 0) { + throw 'Workspace tests failed.' + } + + & $Cargo test -p edit icu::tests::init -- --ignored --exact + if ($LASTEXITCODE -ne 0) { + throw 'ICU runtime test failed.' + } + } + + # EN: Cross-check portable Rust code without linking against foreign OS SDKs on Windows. + # 中文:在 Windows 上檢查跨平台 Rust 程式碼,但不嘗試連結其他作業系統的 SDK。 + foreach ($target in $CrossCheckTargets) { + & $Rustup target add $target + if ($LASTEXITCODE -ne 0) { + throw "Unable to install Rust standard library for $target." + } + + & $Cargo check -p edit --target $target + if ($LASTEXITCODE -ne 0) { + throw "Cross-platform cargo check failed for $target." + } + } + + & $Cargo build -p edit --release + if ($LASTEXITCODE -ne 0) { + throw 'Release build failed.' + } + if (-not (Test-Path -LiteralPath $ReleaseExe -PathType Leaf)) { + throw "Release executable not found: $ReleaseExe" + } + + # EN: Validate the two independent version rows emitted by the executable. + # 中文:驗證執行檔輸出的原始版本與 tzk 版本兩列宣告。 + $version = Get-VersionInfo -Path $ReleaseExe + + New-Item -ItemType Directory -Path $StageDir -Force | Out-Null + Copy-Item -LiteralPath $ReleaseExe -Destination (Join-Path $StageDir 'edit.exe') -Force + Copy-Item -LiteralPath $ReleaseExe -Destination (Join-Path $StageDir 'msedit-tzk.exe') -Force + Copy-Item -LiteralPath (Join-Path $RepoRoot 'LICENSE') -Destination (Join-Path $StageDir 'LICENSE.txt') -Force + + $sha256 = Get-Sha256 -Path $ReleaseExe + $result.success = $true + $result.message = 'Build completed successfully.' + $result.originalVersion = $version.OriginalVersion + $result.tzkVersion = $version.TzkVersion + $result.versionDeclaration = $version.Lines + $result.releaseExe = $ReleaseExe + $result.stageDir = $StageDir + $result.crossCheckTargets = $CrossCheckTargets + $result.sha256 = $sha256 + + Write-Host '' + Write-Host '=== Build completed successfully ===' -ForegroundColor Green + Write-Host "Original version : $($version.OriginalVersion)" + Write-Host "tzk version : $($version.TzkVersion)" + Write-Host "SHA-256: $sha256" + Write-Host "Release : $ReleaseExe" + $exitCode = 0 +} +catch { + $result.message = $_.Exception.Message + Write-Host '' + Write-Host "BUILD FAILED: $($result.message)" -ForegroundColor Red +} +finally { + $result.timestamp = (Get-Date).ToString('o') + $resultDir = Split-Path -Parent $ResultPath + New-Item -ItemType Directory -Path $resultDir -Force | Out-Null + $result | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $ResultPath -Encoding utf8 + + if ($transcriptStarted) { + Stop-Transcript | Out-Null + } + + if ($DisplaySeconds -gt 0) { + Write-Host "Compiler window closes in $DisplaySeconds seconds..." + Start-Sleep -Seconds $DisplaySeconds + } +} + +exit $exitCode diff --git a/build-wsl.sh b/build-wsl.sh new file mode 100644 index 00000000000..efe05807822 --- /dev/null +++ b/build-wsl.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +# Licensed under the MIT License. + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${MSEDIT_TZK_REPO_ROOT:-$SCRIPT_DIR}" +SHARED_ROOT="${MSEDIT_TZK_WSL_SHARED_ROOT:-/mnt/wsl-mount}" +TARGET_DIR="${MSEDIT_TZK_WSL_TARGET_DIR:-$SHARED_ROOT/msedit-tzk-target}" +BUILD_DIR="$SHARED_ROOT/msedit-tzk-build" +TRANSCRIPT_PATH="$BUILD_DIR/build-transcript.txt" +RESULT_PATH="$BUILD_DIR/build-result.txt" +SKIP_TESTS="${MSEDIT_TZK_SKIP_TESTS:-0}" + +# EN: Keep native Linux artifacts on the shared data drive and show every command in the WSL terminal. +# 中文:Linux 原生產物固定留在共用資料碟,所有命令均顯示於 WSL 終端機。 +mkdir -p "$BUILD_DIR" "$TARGET_DIR" +exec > >(tee "$TRANSCRIPT_PATH") 2>&1 + +write_failure() { + local exit_code=$? + printf 'success=false\nexit_code=%s\ntimestamp=%s\n' \ + "$exit_code" "$(date --iso-8601=seconds)" > "$RESULT_PATH" + echo "WSL BUILD FAILED (exit $exit_code)" >&2 + exit "$exit_code" +} +trap write_failure ERR + +if ! mountpoint -q "$SHARED_ROOT"; then + echo "Shared mount is unavailable: $SHARED_ROOT" >&2 + exit 1 +fi +if [[ ! -f "$REPO_ROOT/Cargo.toml" ]]; then + echo "Repository not found: $REPO_ROOT" >&2 + exit 1 +fi +if [[ ! -f "$HOME/.cargo/env" ]]; then + echo "The Ubuntu Rust environment is missing: $HOME/.cargo/env" >&2 + exit 1 +fi +source "$HOME/.cargo/env" +for command_name in cargo rustc rustup gcc pkg-config; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Required WSL build command is missing: $command_name" >&2 + exit 1 + fi +done + +export CARGO_TARGET_DIR="$TARGET_DIR" +cd "$REPO_ROOT" + +echo '=== msedit-tzk visible WSL/Ubuntu build ===' +echo "Ubuntu : $(. /etc/os-release; echo "$PRETTY_NAME")" +echo "Repository: $REPO_ROOT" +echo "Shared : $SHARED_ROOT" +echo "Target : $CARGO_TARGET_DIR" +echo "Rust : $(rustc --version)" +echo "Cargo : $(cargo --version)" +RUSTC_VERBOSE="$(rustc -vV)" +if ! grep -Fqx 'host: x86_64-unknown-linux-gnu' <<< "$RUSTC_VERBOSE"; then + echo 'The active Rust compiler is not the native WSL/Linux toolchain.' >&2 + exit 1 +fi + +rustup component add rustfmt +cargo fmt --all --check +if [[ "$SKIP_TESTS" != '1' ]]; then + cargo test --workspace + cargo test -p edit icu::tests::init -- --ignored --exact +fi +cargo build -p edit --release + +RELEASE_EXE="$CARGO_TARGET_DIR/release/edit" +if [[ ! -x "$RELEASE_EXE" ]]; then + echo "WSL release executable not found: $RELEASE_EXE" >&2 + exit 1 +fi + +mapfile -t VERSION_LINES < <("$RELEASE_EXE" --version) +if [[ "${#VERSION_LINES[@]}" -ne 2 ]] || + [[ ! "${VERSION_LINES[0]}" =~ ^edit\ original\ version:\ [0-9]+\.[0-9]+\.[0-9]+$ ]] || + [[ ! "${VERSION_LINES[1]}" =~ ^tzk\ version:\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo 'Unexpected WSL version declaration.' >&2 + exit 1 +fi + +SHA256="$(sha256sum "$RELEASE_EXE" | awk '{print $1}')" +printf 'success=true\ntimestamp=%s\nrelease=%s\nsha256=%s\n%s\n%s\n' \ + "$(date --iso-8601=seconds)" "$RELEASE_EXE" "$SHA256" \ + "${VERSION_LINES[0]}" "${VERSION_LINES[1]}" > "$RESULT_PATH" +trap - ERR + +echo +echo '=== WSL build completed successfully ===' +printf '%s\n%s\n' "${VERSION_LINES[0]}" "${VERSION_LINES[1]}" +echo "SHA-256: $SHA256" +echo "Release : $RELEASE_EXE" +echo "Log : $TRANSCRIPT_PATH" diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 02becfdb908..ecd495cee26 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -1,5 +1,7 @@ [package] name = "edit" +# EN: Cargo keeps Microsoft's SemVer source version; build/main.rs separately declares tzk 0.0.8. +# 中文:Cargo 保留 Microsoft 原始 SemVer;tzk 0.0.8 由 build/main.rs 獨立宣告。 version = "2.0.0" edition.workspace = true @@ -19,6 +21,9 @@ harness = false debug-latency = [] [dependencies] +# EN: Resolve standard user directories through one portable dependency on every supported OS. +# 中文:在所有支援的作業系統上,透過單一可攜式相依套件解析使用者標準目錄。 +dirs = "6.0.0" lsh.workspace = true stdext.workspace = true diff --git a/crates/edit/benches/lib.rs b/crates/edit/benches/lib.rs index b85787115ce..eb57c01725e 100644 --- a/crates/edit/benches/lib.rs +++ b/crates/edit/benches/lib.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::hint::black_box; diff --git a/crates/edit/build/helpers.rs b/crates/edit/build/helpers.rs index 9690a48f7ce..1a2e8c8e038 100644 --- a/crates/edit/build/helpers.rs +++ b/crates/edit/build/helpers.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::env::VarError; diff --git a/crates/edit/build/i18n.rs b/crates/edit/build/i18n.rs index a01ca7a7451..a94926b47d6 100644 --- a/crates/edit/build/i18n.rs +++ b/crates/edit/build/i18n.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::collections::{BTreeMap, HashMap, HashSet}; diff --git a/crates/edit/build/main.rs b/crates/edit/build/main.rs index 96ae2f20142..c65a02f38e6 100644 --- a/crates/edit/build/main.rs +++ b/crates/edit/build/main.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. #![allow(irrefutable_let_patterns)] @@ -20,6 +21,13 @@ enum TargetOs { fn main() { stdext::arena::init(128 * 1024 * 1024).unwrap(); + // EN: Keep the Microsoft source version and the tzk modification version independent. + // 中文:Microsoft 原始版本與 tzk 修改版本分開管理,避免再組合成單一版本字串。 + let original_version = env!("CARGO_PKG_VERSION"); + let tzk_version = "0.0.8"; + println!("cargo::rustc-env=EDIT_ORIGINAL_VERSION={original_version}"); + println!("cargo::rustc-env=EDIT_TZK_VERSION={tzk_version}"); + let target_os = match env_opt("CARGO_CFG_TARGET_OS").as_str() { "windows" => TargetOs::Windows, "macos" | "ios" => TargetOs::MacOS, @@ -30,7 +38,7 @@ fn main() { compile_i18n(); configure_icu(target_os); #[cfg(windows)] - configure_windows_binary(target_os); + configure_windows_binary(target_os, original_version, tzk_version); } fn compile_lsh() { @@ -129,7 +137,7 @@ fn configure_icu(target_os: TargetOs) { } #[cfg(windows)] -fn configure_windows_binary(target_os: TargetOs) { +fn configure_windows_binary(target_os: TargetOs, original_version: &str, tzk_version: &str) { if target_os != TargetOs::Windows { return; } @@ -140,7 +148,9 @@ fn configure_windows_binary(target_os: TargetOs) { winresource::WindowsResource::new() .set_manifest_file(manifest_path) .set("FileDescription", "Microsoft Edit") - .set("LegalCopyright", "© Microsoft Corporation. All rights reserved.") + .set("FileVersion", original_version) + .set("ProductVersion", &format!("tzk {tzk_version}")) + .set("LegalCopyright", "© Microsoft Corporation. Modifications © 2026 KOSMOS, Tzushih.K.") .set_icon(icon_path) .compile() .unwrap(); diff --git a/crates/edit/src/base64.rs b/crates/edit/src/base64.rs index b659a1f5405..dbea11a4c83 100644 --- a/crates/edit/src/base64.rs +++ b/crates/edit/src/base64.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Base64 facilities. diff --git a/crates/edit/src/bin/edit/apperr.rs b/crates/edit/src/bin/edit/apperr.rs index 0f916a20e19..ec8d6037e34 100644 --- a/crates/edit/src/bin/edit/apperr.rs +++ b/crates/edit/src/bin/edit/apperr.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::io; diff --git a/crates/edit/src/bin/edit/documents.rs b/crates/edit/src/bin/edit/documents.rs index 1ac332a2c2f..e8475c3c100 100644 --- a/crates/edit/src/bin/edit/documents.rs +++ b/crates/edit/src/bin/edit/documents.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::ffi::OsStr; diff --git a/crates/edit/src/bin/edit/draw_boundary.rs b/crates/edit/src/bin/edit/draw_boundary.rs new file mode 100644 index 00000000000..214bbdfeb3d --- /dev/null +++ b/crates/edit/src/bin/edit/draw_boundary.rs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +// Licensed under the MIT License. + +use edit::buffer::TextBuffer; +use edit::framebuffer::IndexedColor; +use edit::helpers::*; +use edit::input::vk; +use edit::tui::*; + +use crate::localization::*; +use crate::state::*; + +pub fn draw_dialog_boundary_align(ctx: &mut Context, state: &mut State) { + // EN: Ask for a positive boundary column, defaulting to 80, before changing the document. + // 中文:修改文件前要求輸入正整數邊界欄位,預設值為 80。 + let mut apply; + let mut cancel; + + ctx.modal_begin("boundary-align", loc(LocId::BoundaryAlignDialogTitle)); + { + ctx.block_begin("boundary-align-content"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(1, 2, 1)); + { + let contains_focus = ctx.contains_focus(); + ctx.label("boundary-align-description", loc(LocId::BoundaryAlignDescription)); + + if ctx.editline("boundary-align-column", &mut state.boundary_align_column) { + state.boundary_align_invalid = false; + } + ctx.attr_intrinsic_size(Size { width: 24, height: 1 }); + if state.boundary_align_invalid { + ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); + + ctx.label("boundary-align-invalid", loc(LocId::BoundaryAlignInvalid)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Red)); + } + + ctx.table_begin("boundary-align-choices"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(1, 0, 0)); + ctx.attr_position(Position::Center); + ctx.table_set_cell_gap(Size { width: 2, height: 0 }); + { + ctx.table_next_row(); + ctx.inherit_focus(); + apply = ctx.button("ok", loc(LocId::Ok), ButtonStyle::default()); + ctx.inherit_focus(); + cancel = ctx.button("cancel", loc(LocId::Cancel), ButtonStyle::default()); + } + ctx.table_end(); + + if contains_focus && ctx.consume_shortcut(vk::RETURN) { + apply = true; + } + } + ctx.block_end(); + } + cancel |= ctx.modal_end(); + + if apply { + let column = + state.boundary_align_column.trim().parse::().ok().filter(|column| *column > 0); + if let Some(column) = column { + if let Some(doc) = state.documents.active_mut() { + let mut tb = doc.buffer.borrow_mut(); + align_buffer_to_boundary(&mut tb, column); + tb.make_cursor_visible(); + } + state.wants_boundary_align = false; + state.boundary_align_column.clear(); + state.boundary_align_column.push_str("80"); + state.boundary_align_invalid = false; + ctx.needs_rerender(); + } else { + state.boundary_align_invalid = true; + ctx.needs_rerender(); + } + } else if cancel { + state.wants_boundary_align = false; + state.boundary_align_column.clear(); + state.boundary_align_column.push_str("80"); + state.boundary_align_invalid = false; + ctx.needs_rerender(); + } +} + +fn align_buffer_to_boundary(tb: &mut TextBuffer, column: usize) { + let text = read_buffer_text(tb); + let aligned = align_text_to_boundary(&String::from_utf8_lossy(&text), column); + if aligned.as_bytes() == text { + return; + } + + tb.select_all(); + tb.write_raw(aligned.as_bytes()); +} + +fn read_buffer_text(tb: &TextBuffer) -> Vec { + let mut text = Vec::with_capacity(tb.text_length()); + while text.len() < tb.text_length() { + let chunk = tb.read_forward(text.len()); + if chunk.is_empty() { + break; + } + text.extend_from_slice(chunk); + } + text +} + +fn align_text_to_boundary(text: &str, column: usize) -> String { + // EN: Overflow becomes carry text joined to the next original line until end-of-file. + // 中文:超出邊界的文字會成為延續內容,與下一原始行合併並持續處理至檔尾。 + if column == 0 || text.is_empty() { + return text.to_string(); + } + + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let has_final_newline = text.ends_with('\n'); + let mut output = Vec::new(); + let mut carry = None::; + + for line in text.lines() { + let mut combined = carry.take().unwrap_or_default(); + combined.push_str(line); + let mut split = false; + + while let Some(byte_offset) = combined.char_indices().nth(column).map(|(offset, _)| offset) + { + output.push(combined[..byte_offset].to_string()); + combined = combined[byte_offset..].to_string(); + split = true; + } + + if split { + carry = Some(combined); + } else { + output.push(combined); + } + } + + if let Some(carry) = carry { + output.push(carry); + } + + let mut aligned = output.join(newline); + if has_final_newline { + aligned.push_str(newline); + } + aligned +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wraps_overflow_and_merges_it_with_the_next_line() { + assert_eq!( + align_text_to_boundary("1234567\nABC\nshort\n12345678901\nXY", 5), + "12345\n67ABC\nshort\n12345\n67890\n1XY" + ); + } + + #[test] + fn counts_unicode_characters_and_preserves_crlf_and_final_newline() { + assert_eq!( + align_text_to_boundary("一二三四五六\r\n甲乙\r\n短\r\n", 5), + "一二三四五\r\n六甲乙\r\n短\r\n" + ); + } + + #[test] + fn keeps_short_lines_unchanged() { + assert_eq!(align_text_to_boundary("one\ntwo\nthree", 80), "one\ntwo\nthree"); + } +} diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index ce03bbeb4cb..0965ed7645a 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use edit::framebuffer::IndexedColor; @@ -12,17 +13,10 @@ use crate::localization::*; use crate::state::*; pub fn draw_editor(ctx: &mut Context, state: &mut State) { - if !matches!(state.wants_search.kind, StateSearchKind::Hidden | StateSearchKind::Disabled) { - draw_search(ctx, state); - } - let size = ctx.size(); - // TODO: The layout code should be able to just figure out the height on its own. - let height_reduction = match state.wants_search.kind { - StateSearchKind::Search => 4, - StateSearchKind::Replace => 5, - _ => 2, - }; + // EN: Search and Replace are centered modals, so the editor always reserves only the menu and status rows. + // 中文:搜尋與取代改為中央對話框,因此編輯區固定只保留選單列與狀態列的高度。 + let height_reduction = 2; if let Some(doc) = state.documents.active() { ctx.textarea("textarea", doc.buffer.clone()); @@ -35,7 +29,7 @@ pub fn draw_editor(ctx: &mut Context, state: &mut State) { ctx.attr_intrinsic_size(Size { width: 0, height: size.height - height_reduction }); } -fn draw_search(ctx: &mut Context, state: &mut State) { +pub fn draw_dialog_search(ctx: &mut Context, state: &mut State) { if let Err(err) = icu::init() { error_log_add(ctx, state, err.into()); state.wants_search.kind = StateSearchKind::Disabled; @@ -47,6 +41,7 @@ fn draw_search(ctx: &mut Context, state: &mut State) { return; }; + let kind = state.wants_search.kind; let mut action = None; let mut focus = StateSearchKind::Hidden; @@ -58,22 +53,33 @@ fn draw_search(ctx: &mut Context, state: &mut State) { // Otherwise, focus the replace input field, if it exists. if let Some(selection) = doc.buffer.borrow_mut().extract_user_selection(false) { state.search_needle = string_from_utf8_lossy_owned(selection); - focus = state.wants_search.kind; + focus = kind; } } - ctx.block_begin("search"); - ctx.attr_focus_well(); - ctx.attr_background_rgba(ctx.indexed(IndexedColor::White)); - ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Black)); - { - if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) { - state.wants_search.kind = StateSearchKind::Hidden; - } + // EN: Draw Search and Replace as centered modal dialogs without reducing the document viewport. + // 中文:以中央對話框繪製搜尋與取代,不再縮小文件的可視編輯區。 + let title = if kind == StateSearchKind::Replace { + loc(LocId::EditReplace) + } else { + loc(LocId::EditFind) + }; + let mut cancel = false; - ctx.table_begin("needle"); - ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + ctx.modal_begin("search", title); + { + ctx.block_begin("search-content"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(1, 2, 1)); { + // EN: F3 continues searching while the modal is open, matching the editor-wide shortcut. + // 中文:對話框開啟時仍可使用 F3 繼續搜尋,與編輯器的全域快捷鍵一致。 + if ctx.contains_focus() && ctx.consume_shortcut(vk::F3) { + action = Some(SearchAction::Search); + } + + ctx.table_begin("search-fields"); + ctx.table_set_cell_gap(Size { width: 1, height: 0 }); { ctx.table_next_row(); ctx.label("label", loc(LocId::SearchNeedleLabel)); @@ -85,75 +91,112 @@ fn draw_search(ctx: &mut Context, state: &mut State) { ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); } - ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 }); + ctx.attr_intrinsic_size(Size { width: 48, height: 1 }); if focus == StateSearchKind::Search { ctx.steal_focus(); } if ctx.is_focused() && ctx.consume_shortcut(vk::RETURN) { action = Some(SearchAction::Search); } - } - if state.wants_search.kind == StateSearchKind::Replace { - ctx.table_next_row(); - ctx.label("label", loc(LocId::SearchReplacementLabel)); + if kind == StateSearchKind::Replace { + ctx.table_next_row(); + ctx.label("label", loc(LocId::SearchReplacementLabel)); - ctx.editline("replacement", &mut state.search_replacement); - ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 }); - if focus == StateSearchKind::Replace { - ctx.steal_focus(); - } - if ctx.is_focused() { - if ctx.consume_shortcut(vk::RETURN) { - action = Some(SearchAction::Replace); - } else if ctx.consume_shortcut(kbmod::CTRL_ALT | vk::RETURN) { - action = Some(SearchAction::ReplaceAll); + ctx.editline("replacement", &mut state.search_replacement); + ctx.attr_intrinsic_size(Size { width: 48, height: 1 }); + if focus == StateSearchKind::Replace { + ctx.steal_focus(); + } + if ctx.is_focused() { + if ctx.consume_shortcut(vk::RETURN) { + action = Some(SearchAction::Replace); + } else if ctx.consume_shortcut(kbmod::CTRL_ALT | vk::RETURN) { + action = Some(SearchAction::ReplaceAll); + } } } } - } - ctx.table_end(); + ctx.table_end(); - ctx.table_begin("options"); - ctx.table_set_cell_gap(Size { width: 2, height: 0 }); - { - let mut change = false; - let mut change_action = Some(SearchAction::Search); + ctx.table_begin("search-options"); + ctx.attr_padding(Rect::three(1, 0, 0)); + ctx.table_set_columns(&[48]); + { + let mut change = false; - ctx.table_next_row(); + // EN: Give every option its own table row so wide translations cannot disturb the modal border. + // 中文:每個選項各自使用一列表格,避免寬字元翻譯破壞對話框外框。 + ctx.table_next_row(); + change |= ctx.checkbox( + "match-case", + loc(LocId::SearchMatchCase), + &mut state.search_options.match_case, + ); - change |= ctx.checkbox( - "match-case", - loc(LocId::SearchMatchCase), - &mut state.search_options.match_case, - ); - change |= ctx.checkbox( - "whole-word", - loc(LocId::SearchWholeWord), - &mut state.search_options.whole_word, - ); - change |= ctx.checkbox( - "use-regex", - loc(LocId::SearchUseRegex), - &mut state.search_options.use_regex, - ); - if state.wants_search.kind == StateSearchKind::Replace - && ctx.button("replace-all", loc(LocId::SearchReplaceAll), ButtonStyle::default()) - { - change = true; - change_action = Some(SearchAction::ReplaceAll); - } - if ctx.button("close", loc(LocId::SearchClose), ButtonStyle::default()) { - state.wants_search.kind = StateSearchKind::Hidden; + ctx.table_next_row(); + change |= ctx.checkbox( + "whole-word", + loc(LocId::SearchWholeWord), + &mut state.search_options.whole_word, + ); + + ctx.table_next_row(); + change |= ctx.checkbox( + "use-regex", + loc(LocId::SearchUseRegex), + &mut state.search_options.use_regex, + ); + if change { + action = Some(SearchAction::Search); + } } + ctx.table_end(); + + // EN: Center the dialog actions and expose every operation that previously depended on Enter shortcuts. + // 中文:將操作按鈕置中,並顯示過去仰賴 Enter 快捷鍵執行的所有動作。 + ctx.table_begin("search-actions"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(1, 0, 0)); + ctx.attr_position(Position::Center); + ctx.table_set_cell_gap(Size { width: 2, height: 0 }); + { + ctx.table_next_row(); + ctx.inherit_focus(); + if ctx.button("find", loc(LocId::EditFind), ButtonStyle::default()) { + action = Some(SearchAction::Search); + } + + if kind == StateSearchKind::Replace { + ctx.inherit_focus(); + if ctx.button("replace", loc(LocId::EditReplace), ButtonStyle::default()) { + action = Some(SearchAction::Replace); + } - if change { - action = change_action; + ctx.inherit_focus(); + if ctx.button( + "replace-all", + loc(LocId::SearchReplaceAll), + ButtonStyle::default(), + ) { + action = Some(SearchAction::ReplaceAll); + } + } + + ctx.inherit_focus(); + cancel |= ctx.button("close", loc(LocId::SearchClose), ButtonStyle::default()); } + ctx.table_end(); } - ctx.table_end(); + ctx.block_end(); + } + cancel |= ctx.modal_end(); + + if cancel { + state.wants_search.kind = StateSearchKind::Hidden; + ctx.needs_rerender(); + return; } - ctx.block_end(); if let Some(action) = action { search_execute(ctx, state, action); @@ -192,19 +235,24 @@ pub fn search_execute(ctx: &mut Context, state: &mut State, action: SearchAction } pub fn draw_handle_save(ctx: &mut Context, state: &mut State) { + let mut show_save_as = false; if let Some(doc) = state.documents.active_mut() { if doc.path.is_some() { if let Err(err) = doc.save(None) { error_log_add(ctx, state, err); } } else { - // No path? Show the file picker. - state.wants_file_picker = StateFilePicker::SaveAs; - state.wants_save = false; - ctx.needs_rerender(); + show_save_as = true; } } + if show_save_as { + // EN: An untitled document opens Save As through the shared Desktop-default helper. + // 中文:未命名文件透過共用輔助函式開啟「另存新檔」,預設位置為桌面。 + show_file_picker(state, StateFilePicker::SaveAs); + ctx.needs_rerender(); + } + state.wants_save = false; } diff --git a/crates/edit/src/bin/edit/draw_filepicker.rs b/crates/edit/src/bin/edit/draw_filepicker.rs index e71b7f81ec3..49629e858f4 100644 --- a/crates/edit/src/bin/edit/draw_filepicker.rs +++ b/crates/edit/src/bin/edit/draw_filepicker.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::cmp::Ordering; @@ -14,6 +15,7 @@ use stdext::arena::scratch_arena; use stdext::collections::BVec; use crate::localization::*; +use crate::settings::Settings; use crate::state::*; pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { @@ -27,9 +29,23 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { } } + // EN: Only Open receives a lower MRU pane; Save As keeps the original single-pane layout. + // 中文:僅「開啟舊檔」加入下方最近檔案欄;另存新檔維持原本單欄配置。 + let opening_file = state.wants_file_picker == StateFilePicker::Open; + let recent_files = + if opening_file { Settings::borrow().recent_files.clone() } else { Vec::new() }; let width = (ctx.size().width - 20).max(10); - let height = (ctx.size().height - 10).max(10); + let height = if opening_file { + (ctx.size().height - 4).max(12) + } else { + (ctx.size().height - 10).max(10) + }; + // EN: Reserve a bordered lower pane even when fewer than five MRU entries exist. + // 中文:即使最近檔案不足五筆,仍保留有框線的下方固定面板。 + let recent_pane_height = if opening_file { (height / 3).clamp(1, 5) } else { 0 }; + let recent_reserved_rows = if opening_file { recent_pane_height + 3 } else { 0 }; let mut doit = None; + let mut recent_open = None; let mut done = false; ctx.modal_begin( @@ -144,7 +160,7 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { // -1 for the label (top) // -1 for the label (bottom) // -1 for the editline (bottom) - height: height - 3, + height: (height - 3 - recent_reserved_rows).max(3), }, ); ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 1, 4)); @@ -170,6 +186,41 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { } ctx.scrollarea_end(); + if opening_file { + // EN: Each absolute path is a one-click button in a distinct lower pane. + // 中文:每一筆絕對路徑都是下方獨立面板中可單擊開啟的按鈕。 + ctx.label("recent-files-label", loc(LocId::FileOpenRecentFiles)); + ctx.attr_padding(Rect::three(1, 1, 0)); + + ctx.scrollarea_begin( + "recent-files-pane", + Size { width: 0, height: recent_pane_height }, + ); + ctx.attr_border(); + ctx.attr_focus_well(); + ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 1, 4)); + { + if recent_files.is_empty() { + ctx.label("recent-files-empty", loc(LocId::FileOpenRecentEmpty)); + ctx.attr_padding(Rect::two(0, 1)); + } else { + for recent in &recent_files { + let display = recent.to_string_lossy(); + if ctx.button( + "recent-file", + &display, + ButtonStyle::default().bracketed(false), + ) { + recent_open = Some(recent.clone()); + } + ctx.attr_overflow(Overflow::TruncateMiddle); + ctx.attr_padding(Rect::two(0, 1)); + } + } + } + ctx.scrollarea_end(); + } + if contains_focus && (ctx.consume_shortcut(vk::BACK) || ctx.consume_shortcut(kbmod::ALT | vk::UP)) { @@ -188,6 +239,10 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { state.file_picker_overwrite_warning = doit.take(); } } + + if let Some(path) = recent_open.take() { + doit = Some(path); + } } if ctx.modal_end() { done = true; @@ -241,15 +296,19 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { } if let Some(path) = doit { - let res = if state.wants_file_picker == StateFilePicker::Open { + let opening_file = state.wants_file_picker == StateFilePicker::Open; + let res = if opening_file { state.documents.add_file_path(&path).map(|_| ()) } else if let Some(doc) = state.documents.active_mut() { - doc.save(Some(path)) + doc.save(Some(path.clone())) } else { Ok(()) }; match res { Ok(..) => { + if opening_file && let Err(err) = Settings::record_recent_file(&path) { + error_log_add(ctx, state, err); + } ctx.needs_rerender(); done = true; } diff --git a/crates/edit/src/bin/edit/draw_menubar.rs b/crates/edit/src/bin/edit/draw_menubar.rs index 401614786a1..b9a4462443a 100644 --- a/crates/edit/src/bin/edit/draw_menubar.rs +++ b/crates/edit/src/bin/edit/draw_menubar.rs @@ -1,19 +1,24 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use edit::helpers::*; use edit::input::{kbmod, vk}; +use edit::oklab::StraightRgba; use edit::tui::*; use stdext::arena_format; +use crate::draw_navigation::document_is_markdown; use crate::localization::*; -use crate::settings::Settings; +use crate::settings::{Settings, Theme}; use crate::state::*; pub fn draw_menubar(ctx: &mut Context, state: &mut State) { + // EN: The customized menu row is always white with black text, independent of the theme. + // 中文:客製選單列固定為白底黑字,不受畫面主題影響。 ctx.menubar_begin(); - ctx.attr_background_rgba(state.menubar_color_bg); - ctx.attr_foreground_rgba(state.menubar_color_fg); + ctx.attr_background_rgba(StraightRgba::from_rgba(0xffffffff)); + ctx.attr_foreground_rgba(StraightRgba::from_rgba(0x000000ff)); { let contains_focus = ctx.contains_focus(); @@ -43,14 +48,14 @@ fn draw_menu_file(ctx: &mut Context, state: &mut State) { draw_add_untitled_document(ctx, state); } if ctx.menubar_menu_button(loc(LocId::FileOpen), 'O', kbmod::CTRL | vk::O) { - state.wants_file_picker = StateFilePicker::Open; + show_file_picker(state, StateFilePicker::Open); } if state.documents.active().is_some() { if ctx.menubar_menu_button(loc(LocId::FileSave), 'S', kbmod::CTRL | vk::S) { state.wants_save = true; } if ctx.menubar_menu_button(loc(LocId::FileSaveAs), 'A', vk::NULL) { - state.wants_file_picker = StateFilePicker::SaveAs; + show_file_picker(state, StateFilePicker::SaveAs); } } #[allow(irrefutable_let_patterns)] @@ -69,6 +74,11 @@ fn draw_menu_file(ctx: &mut Context, state: &mut State) { Err(err) => error_log_add(ctx, state, err), } } + if ctx.menubar_menu_button(loc(LocId::FileTheme), 'T', vk::NULL) { + // EN: Theme selection is independent from the editable JSON settings document. + // 中文:主題選擇為獨立介面,不依賴直接編輯 JSON 設定文件。 + state.wants_theme_picker = true; + } if state.documents.active().is_some() && ctx.menubar_menu_button(loc(LocId::FileClose), 'C', kbmod::CTRL | vk::W) { @@ -118,11 +128,21 @@ fn draw_menu_edit(ctx: &mut Context, state: &mut State) { tb.select_all(); ctx.needs_rerender(); } + drop(tb); + // EN: Boundary Alignment is the final Edit-menu command and starts at column 80. + // 中文:「邊界對齊」位於編輯選單末項,輸入欄預設為第 80 字元。 + if ctx.menubar_menu_button(loc(LocId::EditBoundaryAlign), 'B', vk::NULL) { + state.wants_boundary_align = true; + state.boundary_align_column.clear(); + state.boundary_align_column.push_str("80"); + state.boundary_align_invalid = false; + } ctx.menubar_menu_end(); } fn draw_menu_view(ctx: &mut Context, state: &mut State) { if let Some(doc) = state.documents.active() { + let markdown_path = doc.path.clone().filter(|_| document_is_markdown(doc)); let mut tb = doc.buffer.borrow_mut(); let word_wrap = tb.is_word_wrap_enabled(); @@ -130,6 +150,19 @@ fn draw_menu_view(ctx: &mut Context, state: &mut State) { if ctx.menubar_menu_button(loc(LocId::ViewFocusStatusbar), 'S', vk::NULL) { state.wants_statusbar_focus = true; } + // EN: Keep Navigation second in View, but disable it unless the active file is Markdown. + // 中文:「導覽視窗」固定為檢視選單第二項,非 Markdown 文件時僅反灰停用。 + if let Some(markdown_path) = markdown_path { + if ctx.menubar_menu_button(loc(LocId::ViewNavigation), 'N', vk::NULL) { + if state.navigation_path.as_ref() != Some(&markdown_path) { + state.navigation_collapsed.clear(); + state.navigation_path = Some(markdown_path); + } + state.wants_navigation = true; + } + } else { + ctx.menubar_menu_button_disabled(loc(LocId::ViewNavigation), 'N', vk::NULL); + } if ctx.menubar_menu_button(loc(LocId::ViewGoToFile), 'F', kbmod::CTRL | vk::P) { state.wants_go_to_file = true; } @@ -163,13 +196,27 @@ pub fn draw_dialog_about(ctx: &mut Context, state: &mut State) { ctx.attr_overflow(Overflow::TruncateTail); ctx.attr_position(Position::Center); + // EN: Show the upstream and tzk releases on separate rows. + // 中文:原始版本與 tzk 修改版本各自使用一列顯示。 ctx.label( - "version", + "original-version", &arena_format!( ctx.arena(), "{}{}", - loc(LocId::AboutDialogVersion), - env!("CARGO_PKG_VERSION") + loc(LocId::AboutDialogOriginalVersion), + env!("EDIT_ORIGINAL_VERSION") + ), + ); + ctx.attr_overflow(Overflow::TruncateHead); + ctx.attr_position(Position::Center); + + ctx.label( + "tzk-version", + &arena_format!( + ctx.arena(), + "{}{}", + loc(LocId::AboutDialogTzkVersion), + env!("EDIT_TZK_VERSION") ), ); ctx.attr_overflow(Overflow::TruncateHead); @@ -179,6 +226,10 @@ pub fn draw_dialog_about(ctx: &mut Context, state: &mut State) { ctx.attr_overflow(Overflow::TruncateTail); ctx.attr_position(Position::Center); + ctx.label("modifications-copyright", "Modifications (c) 2026 KOSMOS, Tzushih.K"); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_position(Position::Center); + ctx.block_begin("choices"); ctx.inherit_focus(); ctx.attr_padding(Rect::three(1, 2, 0)); @@ -197,3 +248,40 @@ pub fn draw_dialog_about(ctx: &mut Context, state: &mut State) { state.wants_about = false; } } + +pub fn draw_dialog_theme(ctx: &mut Context, state: &mut State) { + // EN: Present the five persistent display themes as one independent modal choice. + // 中文:以獨立對話框提供五種可保存的畫面主題選擇。 + let mut selected = None; + + ctx.modal_begin("theme", loc(LocId::ThemeDialogTitle)); + { + ctx.list_begin("themes"); + ctx.inherit_focus(); + ctx.focus_on_first_present(); + ctx.attr_padding(Rect::three(1, 2, 1)); + { + for theme in Theme::ALL { + if ctx.list_item(theme == state.theme, theme.display_name()) + == ListSelection::Activated + { + selected = Some(theme); + } + } + } + ctx.list_end(); + } + let close = ctx.modal_end(); + + if let Some(theme) = selected { + state.theme = theme; + state.wants_theme_picker = false; + if let Err(err) = Settings::set_theme(theme) { + error_log_add(ctx, state, err); + } + ctx.needs_rerender(); + } else if close { + state.wants_theme_picker = false; + ctx.needs_rerender(); + } +} diff --git a/crates/edit/src/bin/edit/draw_navigation.rs b/crates/edit/src/bin/edit/draw_navigation.rs new file mode 100644 index 00000000000..d377cba12bd --- /dev/null +++ b/crates/edit/src/bin/edit/draw_navigation.rs @@ -0,0 +1,498 @@ +// Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +// Licensed under the MIT License. + +use edit::buffer::TextBuffer; +use edit::helpers::*; +use edit::oklab::StraightRgba; +use edit::tui::*; + +use crate::documents::Document; +use crate::localization::*; +use crate::state::*; + +const ACTIVE_BACKGROUND: StraightRgba = StraightRgba::from_rgba(0xadd8e6ff); +const ACTIVE_FOREGROUND: StraightRgba = StraightRgba::from_rgba(0x000000ff); + +#[derive(Debug, PartialEq, Eq)] +enum NavigationItemKind { + Heading(String), + Content, +} + +#[derive(Debug, PartialEq, Eq)] +struct NavigationItem { + level: u8, + line: CoordType, + column: CoordType, + kind: NavigationItemKind, +} + +impl NavigationItem { + fn is_heading(&self) -> bool { + matches!(self.kind, NavigationItemKind::Heading(_)) + } +} + +pub fn document_is_markdown(doc: &Document) -> bool { + // EN: The navigation command is enabled exclusively for files ending in .md. + // 中文:導覽功能僅對副檔名為 .md 的文件啟用。 + doc.path + .as_deref() + .and_then(|path| path.extension()) + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) +} + +pub fn draw_dialog_navigation(ctx: &mut Context, state: &mut State) { + // EN: Render a six-level clickable outline with folding and the ESC hint at the bottom. + // 中文:繪製可點選、可折疊的六層大綱,並將 ESC 提示固定放在視窗下緣。 + let Some(doc) = state.documents.active() else { + state.wants_navigation = false; + return; + }; + if !document_is_markdown(doc) { + state.wants_navigation = false; + return; + } + + let (items, cursor_line) = { + let tb = doc.buffer.borrow(); + let text = buffer_text(&tb); + (parse_markdown(&String::from_utf8_lossy(&text)), tb.cursor_logical_pos().y) + }; + let active = active_display_item(&items, cursor_line, &state.navigation_collapsed); + + let size = ctx.size(); + let width = (size.width - 6).clamp(12, 80); + let height = (size.height - 9).max(4); + let mut toggle = None; + let mut jump = None; + + ctx.modal_begin_centered_title("markdown-navigation", loc(LocId::NavigationDialogTitle)); + { + ctx.scrollarea_begin("navigation-scrollarea", Size { width, height }); + ctx.inherit_focus(); + { + if items.is_empty() { + ctx.label("navigation-empty", loc(LocId::NavigationNoContent)); + ctx.attr_padding(Rect::two(0, 1)); + } else { + ctx.table_begin("navigation-tree"); + ctx.inherit_focus(); + ctx.table_set_columns(&[1, COORD_TYPE_SAFE_MAX]); + ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + ctx.attr_padding(Rect::two(0, 1)); + { + let mut hidden_below = None; + for (idx, item) in items.iter().enumerate() { + if let Some(level) = hidden_below { + if item.level > level { + continue; + } + hidden_below = None; + } + + let has_children = item.is_heading() + && items.get(idx + 1).is_some_and(|next| next.level > item.level); + let collapsed = has_children + && state.navigation_collapsed.contains(&(item.line as usize)); + + ctx.table_next_row(); + ctx.next_block_id_mixin(idx as u64 + 1); + if has_children { + if ctx.button( + "navigation-fold", + if collapsed { "+" } else { "-" }, + ButtonStyle::default().bracketed(false), + ) { + toggle = Some(item.line as usize); + } + } else { + ctx.label("navigation-fold-spacer", " "); + } + + let text = tree_text(&items, idx); + ctx.next_block_id_mixin(idx as u64 + 1); + match &item.kind { + NavigationItemKind::Heading(_) => { + if ctx.button( + "navigation-heading", + &text, + ButtonStyle::default().bracketed(false), + ) { + jump = Some(Point { x: item.column, y: item.line }); + } + ctx.attr_overflow(Overflow::TruncateTail); + if active == Some(idx) { + ctx.attr_background_rgba(ACTIVE_BACKGROUND); + ctx.attr_foreground_rgba(ACTIVE_FOREGROUND); + } + } + NavigationItemKind::Content => { + if ctx.button( + "navigation-content", + &text, + ButtonStyle::default().bracketed(false), + ) { + jump = Some(Point { x: 0, y: item.line }); + } + ctx.attr_overflow(Overflow::TruncateTail); + } + } + + if collapsed { + hidden_below = Some(item.level); + } + } + } + ctx.table_end(); + } + } + ctx.scrollarea_end(); + + ctx.label("navigation-escape-hint", loc(LocId::NavigationEscapeHint)); + ctx.attr_position(Position::Center); + ctx.attr_padding(Rect::three(1, 0, 0)); + } + let close = ctx.modal_end(); + + if let Some(line) = toggle { + if !state.navigation_collapsed.remove(&line) { + state.navigation_collapsed.insert(line); + } + ctx.needs_rerender(); + } + + if let Some(pos) = jump + && let Some(doc) = state.documents.active_mut() + { + // EN: A selected tree row moves the editor cursor before the modal closes. + // 中文:點選樹狀項目後,先移動編輯器游標,再關閉導覽視窗。 + let mut tb = doc.buffer.borrow_mut(); + jump_to_navigation_target(&mut tb, pos); + state.wants_navigation = false; + ctx.needs_rerender(); + } + + if close { + state.wants_navigation = false; + ctx.needs_rerender(); + } +} + +fn jump_to_navigation_target(tb: &mut TextBuffer, pos: Point) { + tb.cursor_move_to_logical(pos); + tb.make_cursor_visible(); +} + +fn buffer_text(tb: &TextBuffer) -> Vec { + let mut text = Vec::with_capacity(tb.text_length()); + while text.len() < tb.text_length() { + let chunk = tb.read_forward(text.len()); + if chunk.is_empty() { + break; + } + text.extend_from_slice(chunk); + } + text +} + +fn parse_markdown(text: &str) -> Vec { + // EN: Parse ATX headings # through ###### and summarize other non-empty regions. + // 中文:解析 # 至 ###### 的 ATX 標題,其餘非空白文字區域以概括項目表示。 + let mut items = Vec::new(); + let mut content_start = None; + let mut last_heading_level = 0; + let mut fence = None; + + for (line_number, raw_line) in text.lines().enumerate() { + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + let marker = fence_marker(line); + + if let Some((fence_char, fence_count)) = fence { + note_content(&mut content_start, line_number, last_heading_level, line); + if marker.is_some_and(|(ch, count, rest)| { + ch == fence_char && count >= fence_count && rest.trim().is_empty() + }) { + fence = None; + } + continue; + } + + if let Some((fence_char, fence_count, _)) = marker { + fence = Some((fence_char, fence_count)); + note_content(&mut content_start, line_number, last_heading_level, line); + continue; + } + + if let Some((level, column, title)) = parse_heading(line) { + flush_content(&mut items, &mut content_start); + items.push(NavigationItem { + level, + line: line_number as CoordType, + column: column as CoordType, + kind: NavigationItemKind::Heading(title), + }); + last_heading_level = level; + } else { + note_content(&mut content_start, line_number, last_heading_level, line); + } + } + + flush_content(&mut items, &mut content_start); + items +} + +fn note_content( + content_start: &mut Option<(CoordType, u8)>, + line_number: usize, + last_heading_level: u8, + line: &str, +) { + if content_start.is_none() && !line.trim().is_empty() { + *content_start = Some(( + line_number as CoordType, + if last_heading_level == 0 { 1 } else { last_heading_level + 1 }, + )); + } +} + +fn flush_content(items: &mut Vec, content_start: &mut Option<(CoordType, u8)>) { + if let Some((line, level)) = content_start.take() { + items.push(NavigationItem { level, line, column: 0, kind: NavigationItemKind::Content }); + } +} + +fn fence_marker(line: &str) -> Option<(u8, usize, &str)> { + let bytes = line.as_bytes(); + let mut offset = 0; + while offset < bytes.len() && offset < 3 && bytes[offset] == b' ' { + offset += 1; + } + let fence_char = *bytes.get(offset)?; + if !matches!(fence_char, b'`' | b'~') { + return None; + } + let mut end = offset; + while bytes.get(end) == Some(&fence_char) { + end += 1; + } + let count = end - offset; + (count >= 3).then_some((fence_char, count, &line[end..])) +} + +fn parse_heading(line: &str) -> Option<(u8, usize, String)> { + let bytes = line.as_bytes(); + let mut offset = 0; + while bytes.get(offset).is_some_and(|byte| matches!(*byte, b' ' | b'\t')) { + offset += 1; + } + + let hashes_start = offset; + while bytes.get(offset) == Some(&b'#') { + offset += 1; + } + let level = offset - hashes_start; + if !(1..=6).contains(&level) { + return None; + } + if bytes.get(offset).is_some_and(|byte| !matches!(*byte, b' ' | b'\t')) { + return None; + } + + while bytes.get(offset).is_some_and(|byte| matches!(*byte, b' ' | b'\t')) { + offset += 1; + } + let column = offset; + let mut title_end = bytes.len(); + while title_end > column && matches!(bytes[title_end - 1], b' ' | b'\t') { + title_end -= 1; + } + + let mut closing_start = title_end; + while closing_start > column && bytes[closing_start - 1] == b'#' { + closing_start -= 1; + } + if closing_start < title_end + && closing_start > 0 + && matches!(bytes[closing_start - 1], b' ' | b'\t') + { + title_end = closing_start - 1; + while title_end > column && matches!(bytes[title_end - 1], b' ' | b'\t') { + title_end -= 1; + } + } + + let title = + if title_end == column { "#".repeat(level) } else { line[column..title_end].to_string() }; + let logical_column = line[..column].chars().count(); + Some((level as u8, logical_column, title)) +} + +fn active_display_item( + items: &[NavigationItem], + cursor_line: CoordType, + collapsed: &std::collections::BTreeSet, +) -> Option { + let active = items + .iter() + .enumerate() + .rev() + .find(|(_, item)| item.is_heading() && item.line <= cursor_line) + .map(|(idx, _)| idx)?; + + for (idx, item) in items.iter().enumerate().take(active) { + if item.is_heading() + && collapsed.contains(&(item.line as usize)) + && is_descendant(items, idx, active) + { + return Some(idx); + } + } + Some(active) +} + +fn is_descendant(items: &[NavigationItem], parent: usize, child: usize) -> bool { + if child <= parent || items[child].level <= items[parent].level { + return false; + } + !items[parent + 1..=child].iter().any(|item| item.level <= items[parent].level) +} + +fn tree_text(items: &[NavigationItem], idx: usize) -> String { + let item = &items[idx]; + let has_later_sibling = items[idx + 1..] + .iter() + .take_while(|next| next.level >= item.level) + .any(|next| next.level == item.level); + let mut text = "│ ".repeat(item.level.saturating_sub(1) as usize); + text.push_str(if has_later_sibling { "├─ " } else { "└─ " }); + match &item.kind { + NavigationItemKind::Heading(title) => text.push_str(title), + NavigationItemKind::Content => text.push_str("[......]"), + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_six_heading_levels_and_content_regions() { + let items = parse_markdown( + "intro\n# One\nbody\n## Two\n### Three\n#### Four\n##### Five\n###### Six\ndeep text\n####### Seven\ntail\n# Last\n", + ); + let headings: Vec<_> = items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((item.level, title.as_str(), item.line)), + NavigationItemKind::Content => None, + }) + .collect(); + + assert_eq!( + headings, + vec![ + (1, "One", 1), + (2, "Two", 3), + (3, "Three", 4), + (4, "Four", 5), + (5, "Five", 6), + (6, "Six", 7), + (1, "Last", 11), + ] + ); + assert!(items.iter().all(|item| { + !matches!(&item.kind, NavigationItemKind::Heading(title) if title == "Seven") + })); + } + + #[test] + fn ignores_hashes_without_spaces_and_inside_fences() { + let items = + parse_markdown("# Valid ###\n#invalid\n```markdown\n## Hidden\n```\n ### Visible\n"); + + let headings: Vec<_> = items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((item.level, title.as_str())), + NavigationItemKind::Content => None, + }) + .collect(); + assert_eq!(headings, vec![(1, "Valid"), (3, "Visible")]); + } + + #[test] + fn parses_indented_hash_navigation_levels_and_rejects_asterisks() { + let items = parse_markdown( + "這是開頭\n\n# A\n ## A-1\n ### A-1-1\n ### A-1-2\n ## A-2\n ## A-3\n# B\n\n這是其他文字\n\n# C\n這是測試\n* 非標題\n** 也非標題\n*** 仍非標題\n", + ); + let headings: Vec<_> = items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((item.level, title.as_str())), + NavigationItemKind::Content => None, + }) + .collect(); + assert_eq!( + headings, + vec![ + (1, "A"), + (2, "A-1"), + (3, "A-1-1"), + (3, "A-1-2"), + (2, "A-2"), + (2, "A-3"), + (1, "B"), + (1, "C"), + ] + ); + assert_eq!( + items.iter().filter(|item| matches!(item.kind, NavigationItemKind::Content)).count(), + 3 + ); + + assert_eq!( + items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((title.as_str(), item.column)), + NavigationItemKind::Content => None, + }) + .collect::>(), + vec![ + ("A", 2), + ("A-1", 8), + ("A-1-1", 14), + ("A-1-2", 14), + ("A-2", 8), + ("A-3", 8), + ("B", 2), + ("C", 2), + ] + ); + } + + #[test] + fn collapsed_parent_represents_the_active_descendant() { + let items = parse_markdown("# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\n"); + let collapsed = std::collections::BTreeSet::from([0]); + assert_eq!(active_display_item(&items, 5, &collapsed), Some(0)); + } + + #[test] + fn indented_heading_position_moves_the_document_cursor() { + let text = "開頭\n ###### 第六層\n尾端\n"; + let items = parse_markdown(text); + let heading = items.iter().find(|item| item.is_heading()).unwrap(); + let target = Point { x: heading.column, y: heading.line }; + + let mut tb = TextBuffer::new(false).unwrap(); + tb.write_raw(text.as_bytes()); + jump_to_navigation_target(&mut tb, target); + + assert_eq!(tb.cursor_logical_pos(), Point { x: 12, y: 1 }); + } +} diff --git a/crates/edit/src/bin/edit/draw_statusbar.rs b/crates/edit/src/bin/edit/draw_statusbar.rs index f061527932a..56b03757111 100644 --- a/crates/edit/src/bin/edit/draw_statusbar.rs +++ b/crates/edit/src/bin/edit/draw_statusbar.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use edit::framebuffer::{Attributes, IndexedColor}; @@ -7,6 +8,7 @@ use edit::helpers::*; use edit::icu; use edit::input::vk; use edit::lsh::LANGUAGES; +use edit::oklab::StraightRgba; use edit::tui::*; use stdext::arena::scratch_arena; use stdext::arena_format; @@ -16,10 +18,12 @@ use crate::localization::*; use crate::state::*; pub fn draw_statusbar(ctx: &mut Context, state: &mut State) { + // EN: The customized status row is always green with white text. + // 中文:客製狀態列固定為綠底白字。 ctx.table_begin("statusbar"); ctx.attr_focus_well(); - ctx.attr_background_rgba(state.menubar_color_bg); - ctx.attr_foreground_rgba(state.menubar_color_fg); + ctx.attr_background_rgba(StraightRgba::from_rgba(0x008000ff)); + ctx.attr_foreground_rgba(StraightRgba::from_rgba(0xffffffff)); ctx.table_set_cell_gap(Size { width: 2, height: 0 }); ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 }); ctx.attr_padding(Rect::two(0, 1)); diff --git a/crates/edit/src/bin/edit/localization.rs b/crates/edit/src/bin/edit/localization.rs index e1aaad25781..cc053c64857 100644 --- a/crates/edit/src/bin/edit/localization.rs +++ b/crates/edit/src/bin/edit/localization.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use edit::sys; diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 6bf63456584..21e345e3156 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. mod apperr; mod documents; +mod draw_boundary; mod draw_editor; mod draw_filepicker; mod draw_menubar; +mod draw_navigation; mod draw_statusbar; mod localization; mod settings; @@ -16,9 +19,11 @@ use std::path::Path; use std::time::Duration; use std::{env, process}; +use draw_boundary::*; use draw_editor::*; use draw_filepicker::*; use draw_menubar::*; +use draw_navigation::*; use draw_statusbar::*; use edit::framebuffer::{self, IndexedColor}; use edit::helpers::*; @@ -76,6 +81,7 @@ fn run() -> apperr::Result<()> { if let Err(err) = Settings::reload() { state.add_error(err); } + state.theme = Settings::borrow().theme; if handle_args(&mut state)? { return Ok(()); @@ -95,25 +101,11 @@ fn run() -> apperr::Result<()> { let _restore = setup_terminal(&mut tui, &mut state, &mut vt_parser); - state.menubar_color_bg = tui.indexed(IndexedColor::Background).oklab_blend(tui.indexed_alpha( - IndexedColor::BrightBlue, - 1, - 2, - )); - state.menubar_color_fg = tui.contrasted(state.menubar_color_bg); - let floater_bg = tui - .indexed_alpha(IndexedColor::Background, 2, 3) - .oklab_blend(tui.indexed_alpha(IndexedColor::Foreground, 1, 3)); - let floater_fg = tui.contrasted(floater_bg); tui.setup_modifier_translations(ModifierTranslations { ctrl: loc(LocId::Ctrl), alt: loc(LocId::Alt), shift: loc(LocId::Shift), }); - tui.set_floater_default_bg(floater_bg); - tui.set_floater_default_fg(floater_fg); - tui.set_modal_default_bg(floater_bg); - tui.set_modal_default_fg(floater_fg); sys::inject_window_size_into_stdin(); @@ -293,6 +285,11 @@ fn handle_args(state: &mut State) -> apperr::Result { if let Some(goto) = goto { doc.cursor_move_to_goto(*goto); } + // EN: Files opened from Explorer/CMD/PowerShell also participate in the recent list. + // 中文:由檔案總管、CMD 或 PowerShell 開啟的檔案也會加入最近檔案清單。 + if let Err(err) = Settings::record_recent_file(p) { + state.add_error(err); + } } if dir.is_none() @@ -301,7 +298,8 @@ fn handle_args(state: &mut State) -> apperr::Result { dir = Some(parent.to_path_buf()); } - state.file_picker_pending_dir = DisplayablePathBuf::from_path(dir.unwrap_or(cwd)); + let default_dir = sys::desktop_dir().unwrap_or(cwd); + state.file_picker_pending_dir = DisplayablePathBuf::from_path(dir.unwrap_or(default_dir)); Ok(false) } @@ -333,14 +331,30 @@ fn print_help() { } fn print_version() { - sys::write_stdout(concat!("edit version ", env!("CARGO_PKG_VERSION"), "\n")); + // EN: Version output intentionally uses two lines for source and modification releases. + // 中文:版本輸出固定分成兩列,分別顯示原始版本與修改版本。 + sys::write_stdout(concat!( + "edit original version: ", + env!("EDIT_ORIGINAL_VERSION"), + "\n", + "tzk version: ", + env!("EDIT_TZK_VERSION"), + "\n" + )); } fn draw(ctx: &mut Context, state: &mut State) { + configure_theme(ctx, state); draw_menubar(ctx, state); draw_editor(ctx, state); draw_statusbar(ctx, state); + // EN: Search and Replace now render as centered modal dialogs above the complete editor. + // 中文:搜尋與取代現在以中央對話框呈現在完整編輯器上方。 + if !matches!(state.wants_search.kind, StateSearchKind::Hidden | StateSearchKind::Disabled) { + draw_dialog_search(ctx, state); + } + if state.wants_close { draw_handle_wants_close(ctx, state); } @@ -365,6 +379,15 @@ fn draw(ctx: &mut Context, state: &mut State) { if state.wants_go_to_file { draw_go_to_file(ctx, state); } + if state.wants_theme_picker { + draw_dialog_theme(ctx, state); + } + if state.wants_boundary_align { + draw_dialog_boundary_align(ctx, state); + } + if state.wants_navigation { + draw_dialog_navigation(ctx, state); + } if state.wants_about { draw_dialog_about(ctx, state); } @@ -381,11 +404,11 @@ fn draw(ctx: &mut Context, state: &mut State) { if key == kbmod::CTRL | vk::N { draw_add_untitled_document(ctx, state); } else if key == kbmod::CTRL | vk::O { - state.wants_file_picker = StateFilePicker::Open; + show_file_picker(state, StateFilePicker::Open); } else if key == kbmod::CTRL | vk::S { state.wants_save = true; } else if key == kbmod::CTRL_SHIFT | vk::S { - state.wants_file_picker = StateFilePicker::SaveAs; + show_file_picker(state, StateFilePicker::SaveAs); } else if key == kbmod::CTRL | vk::W { state.wants_close = true; } else if key == kbmod::CTRL | vk::P { @@ -414,6 +437,27 @@ fn draw(ctx: &mut Context, state: &mut State) { } } +fn configure_theme(ctx: &mut Context, state: &mut State) { + // EN: Apply custom editor/modal/selection colors while DEFAULT adapts to the terminal palette. + // 中文:套用客製編輯器、對話框及反白色彩;DEFAULT 則維持隨終端配色調整。 + if let Some(colors) = state.theme.colors() { + ctx.attr_background_rgba(colors.background); + ctx.attr_foreground_rgba(colors.foreground); + + ctx.set_floater_default_colors(colors.background, colors.foreground); + ctx.set_modal_default_colors(colors.background, colors.foreground); + ctx.set_selection_colors(Some((colors.selection_background, colors.selection_foreground))); + } else { + let floater_bg = ctx + .indexed_alpha(IndexedColor::Background, 2, 3) + .oklab_blend(ctx.indexed_alpha(IndexedColor::Foreground, 1, 3)); + let floater_fg = ctx.contrasted(floater_bg); + ctx.set_floater_default_colors(floater_bg, floater_fg); + ctx.set_modal_default_colors(floater_bg, floater_fg); + ctx.set_selection_colors(None); + } +} + fn draw_handle_wants_exit(_ctx: &mut Context, state: &mut State) { while let Some(doc) = state.documents.active() { if doc.buffer.borrow().is_dirty() { diff --git a/crates/edit/src/bin/edit/settings.rs b/crates/edit/src/bin/edit/settings.rs index e29b0970a9b..b1f21935d9b 100644 --- a/crates/edit/src/bin/edit/settings.rs +++ b/crates/edit/src/bin/edit/settings.rs @@ -1,17 +1,122 @@ -use std::path::PathBuf; +// Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +// Licensed under the MIT License. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; use edit::buffer::TextBuffer; use edit::cell::{Ref, SemiRefCell}; -use edit::json; use edit::lsh::{LANGUAGES, Language}; +use edit::oklab::StraightRgba; +use edit::{json, path as edit_path}; use stdext::arena::{read_to_string, scratch_arena}; use stdext::arena_format; use crate::apperr; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Theme { + #[default] + Default, + Mspwb, + Cia, + Mm14, + XtAtStyle, +} + +#[derive(Clone, Copy)] +pub struct ThemeColors { + pub background: StraightRgba, + pub foreground: StraightRgba, + pub selection_background: StraightRgba, + pub selection_foreground: StraightRgba, +} + +impl Theme { + // EN: These are the five user-selectable msedit-tzk screen themes. + // 中文:以下為 msedit-tzk 提供使用者選擇的五種畫面主題。 + pub const ALL: [Theme; 5] = + [Theme::Default, Theme::Mspwb, Theme::Cia, Theme::Mm14, Theme::XtAtStyle]; + + pub const fn display_name(self) -> &'static str { + match self { + Theme::Default => "DEFAULT", + Theme::Mspwb => "MSPWB", + Theme::Cia => "CIA", + Theme::Mm14 => "MM14", + Theme::XtAtStyle => "XT/AT Style", + } + } + + pub const fn setting_name(self) -> &'static str { + match self { + Theme::Default => "DEFAULT", + Theme::Mspwb => "MSPWB", + Theme::Cia => "CIA", + Theme::Mm14 => "MM14", + Theme::XtAtStyle => "XT/AT Style", + } + } + + fn from_setting_name(value: &str) -> Option { + // EN: Accept historical theme labels while always writing the current label. + // 中文:讀取時相容舊主題名稱,儲存時一律寫入目前名稱。 + if value.eq_ignore_ascii_case("DEFAULT") { + Some(Theme::Default) + } else if value.eq_ignore_ascii_case("MSPWB") || value.eq_ignore_ascii_case("Theme MSPWB") { + Some(Theme::Mspwb) + } else if value.eq_ignore_ascii_case("CIA") || value.eq_ignore_ascii_case("Theme CIA") { + Some(Theme::Cia) + } else if value.eq_ignore_ascii_case("MM14") || value.eq_ignore_ascii_case("Theme MM14") { + Some(Theme::Mm14) + } else if value.eq_ignore_ascii_case("XT/AT Style") + || value.eq_ignore_ascii_case("IBM XT/AT") + || value.eq_ignore_ascii_case("Theme IBM XT/AT") + { + Some(Theme::XtAtStyle) + } else { + None + } + } + + pub const fn colors(self) -> Option { + let rgba = StraightRgba::from_rgba; + match self { + Theme::Default => None, + Theme::Mspwb => Some(ThemeColors { + background: rgba(0x0000ffff), + foreground: rgba(0xffffffff), + selection_background: rgba(0x000000ff), + selection_foreground: rgba(0x00ff00ff), + }), + Theme::Cia => Some(ThemeColors { + background: rgba(0x0a2240ff), + foreground: rgba(0xffffffff), + selection_background: rgba(0x000000ff), + selection_foreground: rgba(0x00ff00ff), + }), + Theme::Mm14 => Some(ThemeColors { + background: rgba(0x000000ff), + foreground: rgba(0xe6cea7ff), + selection_background: rgba(0xe6cea7ff), + selection_foreground: rgba(0x000000ff), + }), + Theme::XtAtStyle => Some(ThemeColors { + background: rgba(0x000000ff), + foreground: rgba(0x00ff00ff), + selection_background: rgba(0x00ff00ff), + selection_foreground: rgba(0x000000ff), + }), + } + } +} + pub struct Settings { pub path: PathBuf, + pub theme: Theme, pub file_associations: Vec<(String, &'static Language)>, + pub recent_files: Vec, } struct SettingsCell(SemiRefCell); @@ -22,13 +127,19 @@ impl Settings { /// Fills the given settings.json text buffer with some initial contents for convenience. pub fn bootstrap(tb: &mut TextBuffer) { tb.set_crlf(false); - tb.write_raw(b"{\n}\n"); + let contents = Self::borrow().to_json(); + tb.write_raw(contents.as_bytes()); tb.cursor_move_to_logical(Default::default()); tb.mark_as_clean(); } const fn new() -> Self { - Settings { path: PathBuf::new(), file_associations: Vec::new() } + Settings { + path: PathBuf::new(), + theme: Theme::Default, + file_associations: Vec::new(), + recent_files: Vec::new(), + } } pub fn borrow() -> Ref<'static, Settings> { @@ -65,6 +176,16 @@ impl Settings { return Err(apperr::Error::SettingsInvalid("Non-object root")); }; + if let Some(value) = root.get("theme") { + let Some(value) = value.as_str() else { + return Err(apperr::Error::SettingsInvalid("theme")); + }; + let Some(theme) = Theme::from_setting_name(value) else { + return Err(apperr::Error::SettingsInvalid("theme")); + }; + self.theme = theme; + } + if let Some(f) = root.get_object("files.associations") { for &(mut key, ref value) in f.iter() { if !key.contains('/') { @@ -82,8 +203,119 @@ impl Settings { } } + // EN: Persist at most five unique absolute paths, newest first. + // 中文:最近開啟檔案最多保存五筆不重複的絕對路徑,最新項目在前。 + if let Some(value) = root.get("files.recent") { + let Some(values) = value.as_array() else { + return Err(apperr::Error::SettingsInvalid("files.recent")); + }; + for value in values.iter().take(5) { + let Some(value) = value.as_str() else { + return Err(apperr::Error::SettingsInvalid("files.recent")); + }; + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(apperr::Error::SettingsInvalid("files.recent")); + } + let path = edit_path::normalize(&path); + if !self.recent_files.contains(&path) { + self.recent_files.push(path); + } + } + } + Ok(()) } + + pub fn set_theme(theme: Theme) -> apperr::Result<()> { + let settings = &mut *SETTINGS.0.borrow_mut(); + settings.theme = theme; + settings.save() + } + + pub fn record_recent_file(path: &Path) -> apperr::Result<()> { + // EN: Normalize every successful open before moving it to the front of the MRU list. + // 中文:成功開啟檔案後先正規化路徑,再移至最近檔案清單最前方。 + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let absolute = edit_path::normalize(&absolute); + let settings = &mut *SETTINGS.0.borrow_mut(); + settings.remember_recent_file(absolute); + settings.save() + } + + fn remember_recent_file(&mut self, path: PathBuf) { + self.recent_files.retain(|recent| recent != &path); + self.recent_files.insert(0, path); + self.recent_files.truncate(5); + } + + fn save(&self) -> apperr::Result<()> { + if self.path.as_os_str().is_empty() { + return Ok(()); + } + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(&self.path, self.to_json())?; + Ok(()) + } + + fn to_json(&self) -> String { + // EN: Serialize settings ourselves so generated files always use LF on every platform. + // 中文:自行序列化設定,確保所有平台產生的檔案皆固定使用 LF。 + let mut contents = String::new(); + contents.push_str("{\n \"theme\": "); + write_json_string(&mut contents, self.theme.setting_name()); + contents.push_str(",\n \"files.associations\": {"); + + for (index, (pattern, language)) in self.file_associations.iter().enumerate() { + contents.push_str(if index == 0 { "\n " } else { ",\n " }); + write_json_string(&mut contents, pattern); + contents.push_str(": "); + write_json_string(&mut contents, language.id); + } + + if !self.file_associations.is_empty() { + contents.push('\n'); + contents.push_str(" "); + } + contents.push_str("},\n \"files.recent\": ["); + + for (index, path) in self.recent_files.iter().enumerate() { + contents.push_str(if index == 0 { "\n " } else { ",\n " }); + write_json_string(&mut contents, &path.to_string_lossy()); + } + + if !self.recent_files.is_empty() { + contents.push('\n'); + contents.push_str(" "); + } + contents.push_str("]\n}\n"); + contents + } +} + +fn write_json_string(output: &mut String, value: &str) { + output.push('"'); + for ch in value.chars() { + match ch { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\u{08}' => output.push_str("\\b"), + '\u{0c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + '\0'..='\u{1f}' => _ = write!(output, "\\u{:04x}", ch as u32), + _ => output.push(ch), + } + } + output.push('"'); } fn settings_json_path() -> Option { @@ -93,27 +325,70 @@ fn settings_json_path() -> Option { } fn config_dir() -> Option { - fn var_path(key: &str) -> Option { - std::env::var_os(key).map(PathBuf::from) + // EN: `dirs` follows each OS convention while the app keeps one portable code path. + // 中文:`dirs` 依循各作業系統慣例,應用程式則維持單一可攜式程式路徑。 + dirs::config_dir().map(|path| path.join("TZK").join("Edit")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_names_round_trip() { + for theme in Theme::ALL { + assert_eq!(Theme::from_setting_name(theme.setting_name()), Some(theme)); + assert!(!theme.display_name().starts_with("Theme ")); + } + assert_eq!(Theme::from_setting_name("XT/AT Style"), Some(Theme::XtAtStyle)); + assert_eq!(Theme::from_setting_name("Theme IBM XT/AT"), Some(Theme::XtAtStyle)); + assert_eq!(Theme::from_setting_name("unknown"), None); } - fn push(mut path: PathBuf, suffix: &str) -> PathBuf { - path.push(suffix); - path + #[test] + fn theme_colors_match_specification() { + let mspwb = Theme::Mspwb.colors().unwrap(); + assert_eq!(mspwb.background.to_rgba(), 0x0000ffff); + assert_eq!(mspwb.foreground.to_rgba(), 0xffffffff); + assert_eq!(mspwb.selection_background.to_rgba(), 0x000000ff); + assert_eq!(mspwb.selection_foreground.to_rgba(), 0x00ff00ff); + + let cia = Theme::Cia.colors().unwrap(); + assert_eq!(cia.background.to_rgba(), 0x0a2240ff); + + let mm14 = Theme::Mm14.colors().unwrap(); + assert_eq!(mm14.foreground.to_rgba(), 0xe6cea7ff); + assert_eq!(mm14.selection_background.to_rgba(), 0xe6cea7ff); + + let xt_at = Theme::XtAtStyle.colors().unwrap(); + assert_eq!(xt_at.foreground.to_rgba(), 0x00ff00ff); + assert_eq!(xt_at.selection_background.to_rgba(), 0x00ff00ff); } - #[cfg(target_os = "windows")] - { - var_path("APPDATA").map(|p| push(p, "Microsoft\\Edit")) + #[test] + fn theme_setting_serializes_as_lf_json() { + let mut settings = Settings::new(); + settings.theme = Theme::Cia; + assert_eq!( + settings.to_json(), + "{\n \"theme\": \"CIA\",\n \"files.associations\": {},\n \"files.recent\": []\n}\n" + ); } - #[cfg(any(target_os = "macos", target_os = "ios"))] - { - var_path("HOME").map(|p| push(p, "Library/Application Support/com.microsoft.edit")) + + #[test] + fn recent_files_keep_the_newest_unique_five_paths() { + let mut settings = Settings::new(); + for name in ["one", "two", "three", "four", "five", "six", "four"] { + settings.remember_recent_file(PathBuf::from(name)); + } + assert_eq!( + settings.recent_files, + ["four", "six", "five", "three", "two"].map(PathBuf::from) + ); } - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "ios")))] - { - var_path("XDG_CONFIG_HOME") - .or_else(|| var_path("HOME").map(|p| push(p, ".config"))) - .map(|p| push(p, "msedit")) + + #[test] + fn config_path_uses_the_cross_platform_directory_provider() { + assert_eq!(config_dir(), dirs::config_dir().map(|path| path.join("TZK").join("Edit"))); } } diff --git a/crates/edit/src/bin/edit/state.rs b/crates/edit/src/bin/edit/state.rs index 13a1cefbbea..be435af1096 100644 --- a/crates/edit/src/bin/edit/state.rs +++ b/crates/edit/src/bin/edit/state.rs @@ -1,20 +1,22 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::borrow::Cow; +use std::collections::BTreeSet; use std::ffi::{OsStr, OsString}; use std::mem; use std::path::{Path, PathBuf}; use edit::framebuffer::IndexedColor; use edit::helpers::*; -use edit::oklab::StraightRgba; use edit::tui::*; -use edit::{buffer, icu}; +use edit::{buffer, icu, sys}; use crate::apperr; use crate::documents::DocumentManager; use crate::localization::*; +use crate::settings::Theme; #[repr(transparent)] pub struct FormatApperr(apperr::Error); @@ -131,8 +133,7 @@ pub struct OscTitleFileStatus { } pub struct State { - pub menubar_color_bg: StraightRgba, - pub menubar_color_fg: StraightRgba, + pub theme: Theme, pub documents: DocumentManager, @@ -166,6 +167,13 @@ pub struct State { pub wants_statusbar_focus: bool, pub wants_indentation_picker: bool, pub wants_go_to_file: bool, + pub wants_theme_picker: bool, + pub wants_boundary_align: bool, + pub boundary_align_column: String, + pub boundary_align_invalid: bool, + pub wants_navigation: bool, + pub navigation_collapsed: BTreeSet, + pub navigation_path: Option, pub wants_about: bool, pub wants_close: bool, pub wants_exit: bool, @@ -182,8 +190,7 @@ pub struct State { impl State { pub fn new() -> apperr::Result { Ok(Self { - menubar_color_bg: StraightRgba::zero(), - menubar_color_fg: StraightRgba::zero(), + theme: Theme::Default, documents: Default::default(), @@ -216,6 +223,13 @@ impl State { wants_encoding_change: StateEncodingChange::None, wants_indentation_picker: false, wants_go_to_file: false, + wants_theme_picker: false, + wants_boundary_align: false, + boundary_align_column: "80".into(), + boundary_align_invalid: false, + wants_navigation: false, + navigation_collapsed: Default::default(), + navigation_path: None, wants_about: false, wants_close: false, wants_exit: false, @@ -249,6 +263,38 @@ pub fn draw_add_untitled_document(ctx: &mut Context, state: &mut State) { } } +pub fn show_file_picker(state: &mut State, picker: StateFilePicker) { + // EN: Open and an untitled Save As begin at the actual Windows Desktop known folder. + // 中文:「開啟舊檔」及未命名文件首次另存皆從 Windows 實際桌面 Known Folder 開始。 + debug_assert!(matches!(picker, StateFilePicker::Open | StateFilePicker::SaveAs)); + + let target_dir = if picker == StateFilePicker::Open { + sys::desktop_dir().ok() + } else { + state + .documents + .active() + .and_then(|doc| doc.path.as_deref()) + .and_then(Path::parent) + .map(Path::to_path_buf) + .or_else(|| sys::desktop_dir().ok()) + }; + + if let Some(target_dir) = target_dir + && target_dir != state.file_picker_pending_dir.as_path() + { + state.file_picker_pending_dir = DisplayablePathBuf::from_path(target_dir); + state.file_picker_pending_dir_revision = + state.file_picker_pending_dir_revision.wrapping_add(1); + } + + state.wants_file_picker = picker; + state.file_picker_pending_name = Default::default(); + state.file_picker_entries = None; + state.file_picker_overwrite_warning = None; + state.file_picker_autocomplete.clear(); +} + pub fn error_log_add(ctx: &mut Context, state: &mut State, err: apperr::Error) { if state.add_error(err) { ctx.needs_rerender(); @@ -288,3 +334,26 @@ pub fn draw_error_log(ctx: &mut Context, state: &mut State) { state.error_log_count = 0; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_and_untitled_save_as_use_the_resolved_desktop_when_available() { + // EN: Headless CI may not expose a Desktop, so only assert when the provider resolves one. + // 中文:無介面的 CI 可能沒有桌面目錄,因此僅在成功解析時進行斷言。 + let Ok(desktop) = sys::desktop_dir() else { + return; + }; + + let mut state = State::new().unwrap(); + show_file_picker(&mut state, StateFilePicker::Open); + assert_eq!(state.file_picker_pending_dir.as_path(), desktop); + + state.documents.add_untitled().unwrap(); + state.file_picker_pending_dir = DisplayablePathBuf::from_path(PathBuf::from("Z:\\")); + show_file_picker(&mut state, StateFilePicker::SaveAs); + assert_eq!(state.file_picker_pending_dir.as_path(), desktop); + } +} diff --git a/crates/edit/src/buffer/gap_buffer.rs b/crates/edit/src/buffer/gap_buffer.rs index da4cc2769af..2da28e473f9 100644 --- a/crates/edit/src/buffer/gap_buffer.rs +++ b/crates/edit/src/buffer/gap_buffer.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::ops::Range; diff --git a/crates/edit/src/buffer/mod.rs b/crates/edit/src/buffer/mod.rs index 58361e20b58..6848919d150 100644 --- a/crates/edit/src/buffer/mod.rs +++ b/crates/edit/src/buffer/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! A text buffer for a text editor. @@ -1793,6 +1794,7 @@ impl TextBuffer { let text_width = width - self.margin_width; let mut visualizer_buf = [0xE2, 0x90, 0x80]; // U+2400 in UTF8 let mut visual_pos_x_max = 0; + let mut selection_rects = Vec::new(); // Pick the cursor closer to the `origin.y`. let mut cursor = { @@ -1912,17 +1914,10 @@ impl TextBuffer { bottom: top + 1, }; - let mut bg = fb.indexed(IndexedColor::Foreground).oklab_blend(fb.indexed_alpha( - IndexedColor::BrightBlue, - 1, - 2, - )); - if !focused { - bg = bg.oklab_blend(fb.indexed_alpha(IndexedColor::Background, 1, 2)); - }; - let fg = fb.contrasted(bg); + let (bg, fg) = fb.selection_colors(focused); fb.blend_bg(rect, bg); fb.blend_fg(rect, fg); + selection_rects.push(rect); } // Nothing to do if the entire line is empty. @@ -2052,6 +2047,14 @@ impl TextBuffer { let logical_y_end = cursor.logical_pos.y + 1; self.render_apply_highlights(origin, destination, logical_y_beg..logical_y_end, fb); + // EN: Selection colors must win over syntax highlighting. + // 中文:反白區域色彩必須覆蓋語法醒目提示色彩。 + let (selection_bg, selection_fg) = fb.selection_colors(focused); + for rect in selection_rects { + fb.blend_bg(rect, selection_bg); + fb.blend_fg(rect, selection_fg); + } + // Colorize the margin that we wrote above. if self.margin_width > 0 { let margin = Rect { diff --git a/crates/edit/src/buffer/navigation.rs b/crates/edit/src/buffer/navigation.rs index 2cbbead3642..d81a3d603e8 100644 --- a/crates/edit/src/buffer/navigation.rs +++ b/crates/edit/src/buffer/navigation.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::ops::Range; diff --git a/crates/edit/src/cell.rs b/crates/edit/src/cell.rs index cb60ac06548..fa7b6ff6021 100644 --- a/crates/edit/src/cell.rs +++ b/crates/edit/src/cell.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! [`std::cell::RefCell`], but without runtime checks in release builds. diff --git a/crates/edit/src/clipboard.rs b/crates/edit/src/clipboard.rs index 741931e25bb..74c7b7e481c 100644 --- a/crates/edit/src/clipboard.rs +++ b/crates/edit/src/clipboard.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Clipboard facilities for the editor. diff --git a/crates/edit/src/document.rs b/crates/edit/src/document.rs index 493eb013ac1..65574709e3f 100644 --- a/crates/edit/src/document.rs +++ b/crates/edit/src/document.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Abstractions over reading/writing arbitrary text containers. diff --git a/crates/edit/src/framebuffer.rs b/crates/edit/src/framebuffer.rs index 7933d612530..446f3ebf7ce 100644 --- a/crates/edit/src/framebuffer.rs +++ b/crates/edit/src/framebuffer.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! A shoddy framebuffer for terminal applications. @@ -115,6 +116,7 @@ pub struct Framebuffer { contrast_colors: [Cell<(StraightRgba, StraightRgba)>; CACHE_TABLE_SIZE], background_fill: StraightRgba, foreground_fill: StraightRgba, + selection_colors: Option<(StraightRgba, StraightRgba)>, } impl Framebuffer { @@ -133,6 +135,7 @@ impl Framebuffer { CACHE_TABLE_SIZE], background_fill: DEFAULT_THEME[IndexedColor::Background as usize], foreground_fill: DEFAULT_THEME[IndexedColor::Foreground as usize], + selection_colors: None, } } @@ -164,6 +167,30 @@ impl Framebuffer { } } + /// EN: Overrides selected-text colors; `None` restores the adaptive defaults. + /// 中文:覆寫反白文字色彩;`None` 恢復自動調整的預設色彩。 + pub fn set_selection_colors(&mut self, colors: Option<(StraightRgba, StraightRgba)>) { + self.selection_colors = colors; + } + + pub fn selection_colors(&self, focused: bool) -> (StraightRgba, StraightRgba) { + // EN: Explicit theme colors take priority over the original terminal-derived colors. + // 中文:主題指定的色彩優先於原本依終端配色推導的色彩。 + if let Some(colors) = self.selection_colors { + return colors; + } + + let mut bg = self.indexed(IndexedColor::Foreground).oklab_blend(self.indexed_alpha( + IndexedColor::BrightBlue, + 1, + 2, + )); + if !focused { + bg = bg.oklab_blend(self.indexed_alpha(IndexedColor::Background, 1, 2)); + } + (bg, self.contrasted(bg)) + } + /// Begins a new frame with the given `size`. pub fn flip(&mut self, size: Size) { if size != self.buffers[0].bg_bitmap.size { diff --git a/crates/edit/src/fuzzy.rs b/crates/edit/src/fuzzy.rs index 92fb945e7f6..0ca40cf5c7f 100644 --- a/crates/edit/src/fuzzy.rs +++ b/crates/edit/src/fuzzy.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Fuzzy search algorithm based on the one used in VS Code (`/src/vs/base/common/fuzzyScorer.ts`). diff --git a/crates/edit/src/hash.rs b/crates/edit/src/hash.rs index d06837cb420..91a4a4fd68d 100644 --- a/crates/edit/src/hash.rs +++ b/crates/edit/src/hash.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Provides fast, non-cryptographic hash functions. diff --git a/crates/edit/src/helpers.rs b/crates/edit/src/helpers.rs index e85b62f7dc6..8bdcf7acf21 100644 --- a/crates/edit/src/helpers.rs +++ b/crates/edit/src/helpers.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Random assortment of helpers I didn't know where to put. diff --git a/crates/edit/src/icu.rs b/crates/edit/src/icu.rs index bb50ef44fbf..e7c837d91bd 100644 --- a/crates/edit/src/icu.rs +++ b/crates/edit/src/icu.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Bindings to the ICU library. diff --git a/crates/edit/src/input.rs b/crates/edit/src/input.rs index 09144fb7b64..4c27196a82c 100644 --- a/crates/edit/src/input.rs +++ b/crates/edit/src/input.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Parses VT sequences into input events. diff --git a/crates/edit/src/json.rs b/crates/edit/src/json.rs index 54489ec4cc9..fe2977d9342 100644 --- a/crates/edit/src/json.rs +++ b/crates/edit/src/json.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! A simple JSONC parser with trailing comma support. diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 585b3bc928f..8583c740fcb 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. #![cfg_attr( diff --git a/crates/edit/src/lsh/cache.rs b/crates/edit/src/lsh/cache.rs index 95788d95ddd..c21a3167c49 100644 --- a/crates/edit/src/lsh/cache.rs +++ b/crates/edit/src/lsh/cache.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use lsh::runtime::Highlight; diff --git a/crates/edit/src/lsh/definitions.rs b/crates/edit/src/lsh/definitions.rs index 7c3cea5cfff..b0e15e64b17 100644 --- a/crates/edit/src/lsh/definitions.rs +++ b/crates/edit/src/lsh/definitions.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. include!(concat!(env!("OUT_DIR"), "/lsh_definitions.rs")); diff --git a/crates/edit/src/lsh/highlighter.rs b/crates/edit/src/lsh/highlighter.rs index 376f9cc5064..df548f96659 100644 --- a/crates/edit/src/lsh/highlighter.rs +++ b/crates/edit/src/lsh/highlighter.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use lsh::runtime::*; diff --git a/crates/edit/src/lsh/mod.rs b/crates/edit/src/lsh/mod.rs index 986083bfce8..512e30803b8 100644 --- a/crates/edit/src/lsh/mod.rs +++ b/crates/edit/src/lsh/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Microsoft Edit's adapter to LSH. diff --git a/crates/edit/src/oklab.rs b/crates/edit/src/oklab.rs index db9bff52ea6..8762a190983 100644 --- a/crates/edit/src/oklab.rs +++ b/crates/edit/src/oklab.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Oklab colorspace conversions. diff --git a/crates/edit/src/path.rs b/crates/edit/src/path.rs index 4ebd59935e5..f7f99b3185d 100644 --- a/crates/edit/src/path.rs +++ b/crates/edit/src/path.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Path related helpers. diff --git a/crates/edit/src/simd/lines_bwd.rs b/crates/edit/src/simd/lines_bwd.rs index 37a016906f9..349823fc310 100644 --- a/crates/edit/src/simd/lines_bwd.rs +++ b/crates/edit/src/simd/lines_bwd.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::ptr; diff --git a/crates/edit/src/simd/lines_fwd.rs b/crates/edit/src/simd/lines_fwd.rs index c05d827d8d3..8ffbf656a23 100644 --- a/crates/edit/src/simd/lines_fwd.rs +++ b/crates/edit/src/simd/lines_fwd.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::ptr; diff --git a/crates/edit/src/simd/memchr2.rs b/crates/edit/src/simd/memchr2.rs index f6e83d1ad05..96bfd2a4710 100644 --- a/crates/edit/src/simd/memchr2.rs +++ b/crates/edit/src/simd/memchr2.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! `memchr`, but with two needles. diff --git a/crates/edit/src/simd/mod.rs b/crates/edit/src/simd/mod.rs index ed6e7b35e2e..e40b935b742 100644 --- a/crates/edit/src/simd/mod.rs +++ b/crates/edit/src/simd/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Provides various high-throughput utilities. diff --git a/crates/edit/src/sys/mod.rs b/crates/edit/src/sys/mod.rs index 91d59881248..b6de74a0824 100644 --- a/crates/edit/src/sys/mod.rs +++ b/crates/edit/src/sys/mod.rs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Platform abstractions. +use std::io; +use std::path::PathBuf; + #[cfg(unix)] mod unix; #[cfg(windows)] @@ -15,3 +19,11 @@ pub use std::fs::canonicalize; pub use unix::*; #[cfg(windows)] pub use windows::*; + +/// EN: Resolves the Desktop through one cross-platform abstraction instead of OS APIs in app code. +/// 中文:透過單一跨平台抽象解析桌面路徑,避免應用程式碼直接使用特定作業系統 API。 +pub fn desktop_dir() -> io::Result { + dirs::desktop_dir().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "the Desktop directory is unavailable") + }) +} diff --git a/crates/edit/src/sys/unix.rs b/crates/edit/src/sys/unix.rs index 1d51fa6f3d0..f26591e25d2 100644 --- a/crates/edit/src/sys/unix.rs +++ b/crates/edit/src/sys/unix.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Unix-specific platform code. diff --git a/crates/edit/src/sys/windows.rs b/crates/edit/src/sys/windows.rs index 98a100835d6..ab109278a2d 100644 --- a/crates/edit/src/sys/windows.rs +++ b/crates/edit/src/sys/windows.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::ffi::{OsString, c_char, c_void}; diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 9826c91cfc7..0c22cb78093 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! An immediate mode UI framework for terminals. @@ -321,6 +322,7 @@ pub struct Tui { floater_default_fg: StraightRgba, modal_default_bg: StraightRgba, modal_default_fg: StraightRgba, + selection_colors: Option<(StraightRgba, StraightRgba)>, /// Last known terminal size. /// @@ -403,6 +405,7 @@ impl Tui { floater_default_fg: StraightRgba::zero(), modal_default_bg: StraightRgba::zero(), modal_default_fg: StraightRgba::zero(), + selection_colors: None, size: Size { width: 0, height: 0 }, mouse_position: Point::MIN, @@ -465,6 +468,13 @@ impl Tui { self.modal_default_fg = color; } + /// EN: Overrides selected/focused colors; `None` restores adaptive reverse video. + /// 中文:覆寫選取/聚焦色彩;`None` 恢復隨終端調整的反相顯示。 + pub fn set_selection_colors(&mut self, colors: Option<(StraightRgba, StraightRgba)>) { + self.selection_colors = colors; + self.framebuffer.set_selection_colors(colors); + } + /// If the TUI is currently running animations, etc., /// this will return a timeout smaller than [`time::Duration::MAX`]. pub fn read_timeout(&mut self) -> time::Duration { @@ -617,7 +627,7 @@ impl Tui { // This root is modal and swallows all clicks, // no matter whether the click was inside it or not. - if matches!(root.borrow().content, NodeContent::Modal(_)) { + if matches!(root.borrow().content, NodeContent::Modal(..)) { break; } } @@ -965,7 +975,7 @@ impl Tui { self.framebuffer.replace_attr(outer_clipped, Attributes::All, Attributes::None); - if matches!(node.content, NodeContent::Modal(_)) { + if matches!(node.content, NodeContent::Modal(..)) { let rect = Rect { left: 0, top: 0, right: self.size.width, bottom: self.size.height }; let dim = self.indexed_alpha(IndexedColor::Background, 1, 2); @@ -988,10 +998,19 @@ impl Tui { } match &mut node.content { - NodeContent::Modal(title) if !title.is_empty() => { + NodeContent::Modal(title, centered) if !title.is_empty() => { + let title_left = if *centered { + let title_width = unicode::MeasurementConfig::new(&title.as_bytes()) + .goto_visual(Point { x: CoordType::MAX, y: 0 }) + .visual_pos + .x; + node.outer.left + (node.outer.width() - title_width) / 2 + } else { + node.outer.left + 2 + }; self.framebuffer.replace_text( node.outer.top, - node.outer.left + 2, + title_left.max(node.outer.left + 1), node.outer.right - 1, title, ); @@ -1443,6 +1462,20 @@ impl<'a> Context<'a, '_> { self.tui.framebuffer.contrasted(color) } + pub fn set_floater_default_colors(&mut self, bg: StraightRgba, fg: StraightRgba) { + self.tui.set_floater_default_bg(bg); + self.tui.set_floater_default_fg(fg); + } + + pub fn set_modal_default_colors(&mut self, bg: StraightRgba, fg: StraightRgba) { + self.tui.set_modal_default_bg(bg); + self.tui.set_modal_default_fg(fg); + } + + pub fn set_selection_colors(&mut self, colors: Option<(StraightRgba, StraightRgba)>) { + self.tui.set_selection_colors(colors); + } + /// Returns the clipboard. pub fn clipboard_ref(&self) -> &Clipboard { &self.tui.clipboard @@ -1724,7 +1757,12 @@ impl<'a> Context<'a, '_> { /// Background and foreground colors are swapped. pub fn attr_reverse(&mut self) { let mut last_node = self.tree.last_node.borrow_mut(); - last_node.attributes.reverse = true; + if let Some((bg, fg)) = self.tui.selection_colors { + last_node.attributes.bg = bg; + last_node.attributes.fg = fg; + } else { + last_node.attributes.reverse = true; + } } /// Checks if the current keyboard input matches the given shortcut, @@ -1781,6 +1819,16 @@ impl<'a> Context<'a, '_> { /// Begins a modal window. Call [`Context::modal_end()`]. pub fn modal_begin(&mut self, classname: &'static str, title: &str) { + self.modal_begin_internal(classname, title, false); + } + + /// EN: Begins a modal whose title is centered in the top border; call [`Context::modal_end()`]. + /// 中文:建立標題置於上框線中央的對話框;結束時呼叫 [`Context::modal_end()`]。 + pub fn modal_begin_centered_title(&mut self, classname: &'static str, title: &str) { + self.modal_begin_internal(classname, title, true); + } + + fn modal_begin_internal(&mut self, classname: &'static str, title: &str, centered_title: bool) { self.block_begin(classname); self.attr_float(FloatSpec { anchor: Anchor::Root, @@ -1801,7 +1849,7 @@ impl<'a> Context<'a, '_> { } else { arena_format!(self.arena(), " {} ", title) }; - last_node.content = NodeContent::Modal(title); + last_node.content = NodeContent::Modal(title, centered_title); self.last_modal = Some(self.tree.last_node); } @@ -2074,17 +2122,18 @@ impl<'a> Context<'a, '_> { self.button_activated() } - /// Creates a checkbox with the given text. - /// Returns true if the checkbox was activated. + /// EN: Creates a checkbox with ASCII markers whose width is stable across terminal platforms. + /// 中文:使用跨終端機平台寬度一致的 ASCII 標記建立核取方塊。 + /// EN: Returns true if the checkbox was activated. + /// 中文:核取方塊啟用時回傳 true。 pub fn checkbox(&mut self, classname: &'static str, text: &str, checked: &mut bool) -> bool { self.styled_label_begin(classname); self.attr_focusable(); if self.is_focused() { self.attr_reverse(); } - self.styled_label_add_text(if *checked { "[◼ " } else { "[◻ " }); + self.styled_label_add_text(if *checked { "[x] " } else { "[ ] " }); self.styled_label_add_text(text); - self.styled_label_add_text("]"); self.styled_label_end(); let activated = self.button_activated(); @@ -3268,6 +3317,24 @@ impl<'a> Context<'a, '_> { self.menubar_menu_checkbox(text, accelerator, shortcut, false) } + /// EN: Appends a visible but non-interactive disabled button to the current menu. + /// 中文:在目前選單加入可見但不可互動的反灰按鈕。 + pub fn menubar_menu_button_disabled( + &mut self, + text: &str, + accelerator: char, + shortcut: InputKey, + ) { + self.table_next_row(); + self.attr_foreground_rgba(self.indexed(IndexedColor::BrightBlack)); + self.button_label( + "menu_button_disabled", + text, + ButtonStyle::default().bracketed(false).checked(false).accelerator(accelerator), + ); + self.menubar_shortcut(shortcut); + } + /// Appends a checkbox to the current menu. /// Returns true if the checkbox was activated. pub fn menubar_menu_checkbox( @@ -3793,7 +3860,7 @@ enum NodeContent<'a> { #[default] None, List(ListContent<'a>), - Modal(BString<'a>), // title + Modal(BString<'a>, bool), // title, centered Table(TableContent<'a>), Text(TextContent<'a>), Textarea(TextareaContent<'a>), diff --git a/crates/edit/src/unicode/measurement.rs b/crates/edit/src/unicode/measurement.rs index aab03204589..046a8ac465b 100644 --- a/crates/edit/src/unicode/measurement.rs +++ b/crates/edit/src/unicode/measurement.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use stdext::cold_path; diff --git a/crates/edit/src/unicode/mod.rs b/crates/edit/src/unicode/mod.rs index 43bd25c361a..bdc814766fe 100644 --- a/crates/edit/src/unicode/mod.rs +++ b/crates/edit/src/unicode/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Everything related to Unicode lives here. diff --git a/crates/edit/src/unicode/tables.rs b/crates/edit/src/unicode/tables.rs index ee88b873148..d26eb560c95 100644 --- a/crates/edit/src/unicode/tables.rs +++ b/crates/edit/src/unicode/tables.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. // BEGIN: Generated by grapheme-table-gen on 2025-06-03T13:50:48Z, from Unicode 16.0.0, with --lang=rust --extended --line-breaks, 17688 bytes diff --git a/crates/edit/src/vt.rs b/crates/edit/src/vt.rs index 451d1a213ea..447c543fa61 100644 --- a/crates/edit/src/vt.rs +++ b/crates/edit/src/vt.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Our VT parser. diff --git a/crates/lsh-bin/src/main.rs b/crates/lsh-bin/src/main.rs index fbaeb26ebeb..7120619f955 100644 --- a/crates/lsh-bin/src/main.rs +++ b/crates/lsh-bin/src/main.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::fs::File; diff --git a/crates/lsh/src/compiler/backend.rs b/crates/lsh/src/compiler/backend.rs index 8a5016147c9..66dd43f69e7 100644 --- a/crates/lsh/src/compiler/backend.rs +++ b/crates/lsh/src/compiler/backend.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Backend: IR graph -> bytecode diff --git a/crates/lsh/src/compiler/frontend.rs b/crates/lsh/src/compiler/frontend.rs index 3f1833d824d..687e72b0de5 100644 --- a/crates/lsh/src/compiler/frontend.rs +++ b/crates/lsh/src/compiler/frontend.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Frontend: DSL source code -> IR graph diff --git a/crates/lsh/src/compiler/generator.rs b/crates/lsh/src/compiler/generator.rs index 55d5e5fe986..4c964cd124e 100644 --- a/crates/lsh/src/compiler/generator.rs +++ b/crates/lsh/src/compiler/generator.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Code generators for different output formats. diff --git a/crates/lsh/src/compiler/mod.rs b/crates/lsh/src/compiler/mod.rs index 9004581176a..62398465812 100644 --- a/crates/lsh/src/compiler/mod.rs +++ b/crates/lsh/src/compiler/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! The LSH compiler diff --git a/crates/lsh/src/compiler/optimizer.rs b/crates/lsh/src/compiler/optimizer.rs index 9f9f8fc8bf4..a66b110446f 100644 --- a/crates/lsh/src/compiler/optimizer.rs +++ b/crates/lsh/src/compiler/optimizer.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! IR optimization passes, run before register allocation. diff --git a/crates/lsh/src/compiler/regex.rs b/crates/lsh/src/compiler/regex.rs index 904dd6a334e..11868591786 100644 --- a/crates/lsh/src/compiler/regex.rs +++ b/crates/lsh/src/compiler/regex.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Regex -> IR Compiler diff --git a/crates/lsh/src/lib.rs b/crates/lsh/src/lib.rs index 328fbcd1e3e..5538668793b 100644 --- a/crates/lsh/src/lib.rs +++ b/crates/lsh/src/lib.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Welcome to the Lightweight Syntax Highlighter (LSH), otherwise known as diff --git a/crates/lsh/src/runtime.rs b/crates/lsh/src/runtime.rs index e9e767786a2..bfd03072295 100644 --- a/crates/lsh/src/runtime.rs +++ b/crates/lsh/src/runtime.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! LSH bytecode interpreter. diff --git a/crates/stdext/src/alloc.rs b/crates/stdext/src/alloc.rs index 9d7e4dc7bf3..4b62a6b172a 100644 --- a/crates/stdext/src/alloc.rs +++ b/crates/stdext/src/alloc.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::alloc::{Layout, alloc, dealloc, handle_alloc_error, realloc}; diff --git a/crates/stdext/src/arena/debug.rs b/crates/stdext/src/arena/debug.rs index c2a92481f59..5e34ef8e921 100644 --- a/crates/stdext/src/arena/debug.rs +++ b/crates/stdext/src/arena/debug.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. #![allow(clippy::missing_safety_doc, clippy::mut_from_ref)] diff --git a/crates/stdext/src/arena/fs.rs b/crates/stdext/src/arena/fs.rs index 72037493a15..87b43939c67 100644 --- a/crates/stdext/src/arena/fs.rs +++ b/crates/stdext/src/arena/fs.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::fs::File; diff --git a/crates/stdext/src/arena/mod.rs b/crates/stdext/src/arena/mod.rs index e946a6e68f1..67b93e20812 100644 --- a/crates/stdext/src/arena/mod.rs +++ b/crates/stdext/src/arena/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Arena allocators. Small and fast. diff --git a/crates/stdext/src/arena/release.rs b/crates/stdext/src/arena/release.rs index c39e712d39b..ecc739a1f78 100644 --- a/crates/stdext/src/arena/release.rs +++ b/crates/stdext/src/arena/release.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. #![allow(clippy::mut_from_ref)] diff --git a/crates/stdext/src/arena/scratch.rs b/crates/stdext/src/arena/scratch.rs index 57645bee9ef..81d68876a20 100644 --- a/crates/stdext/src/arena/scratch.rs +++ b/crates/stdext/src/arena/scratch.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::io; diff --git a/crates/stdext/src/collections/mod.rs b/crates/stdext/src/collections/mod.rs index ca6ab962396..2e926a0cc96 100644 --- a/crates/stdext/src/collections/mod.rs +++ b/crates/stdext/src/collections/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. mod string; diff --git a/crates/stdext/src/collections/string.rs b/crates/stdext/src/collections/string.rs index 5da255576ec..eec0f499504 100644 --- a/crates/stdext/src/collections/string.rs +++ b/crates/stdext/src/collections/string.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::fmt::{self}; diff --git a/crates/stdext/src/collections/vec.rs b/crates/stdext/src/collections/vec.rs index 1c8c8abc629..794cc3aa2dc 100644 --- a/crates/stdext/src/collections/vec.rs +++ b/crates/stdext/src/collections/vec.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::hint::assert_unchecked; diff --git a/crates/stdext/src/float.rs b/crates/stdext/src/float.rs index 52a4de2246f..397d2b0ac06 100644 --- a/crates/stdext/src/float.rs +++ b/crates/stdext/src/float.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! A simple, tiny, approximate (!) float parser. diff --git a/crates/stdext/src/glob.rs b/crates/stdext/src/glob.rs index 2a9a75adb4d..b3309eefb36 100644 --- a/crates/stdext/src/glob.rs +++ b/crates/stdext/src/glob.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Simple glob matching. diff --git a/crates/stdext/src/helpers.rs b/crates/stdext/src/helpers.rs index 8396cfcf918..656b4d93e24 100644 --- a/crates/stdext/src/helpers.rs +++ b/crates/stdext/src/helpers.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Random assortment of helpers I didn't know where to put. diff --git a/crates/stdext/src/lib.rs b/crates/stdext/src/lib.rs index 860b6ad563e..0fa814780be 100644 --- a/crates/stdext/src/lib.rs +++ b/crates/stdext/src/lib.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Arena allocators. Small and fast. diff --git a/crates/stdext/src/simd/memset.rs b/crates/stdext/src/simd/memset.rs index 15d921220f3..e27a312e618 100644 --- a/crates/stdext/src/simd/memset.rs +++ b/crates/stdext/src/simd/memset.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! `memchr` for arbitrary sizes (1/2/4/8 bytes). diff --git a/crates/stdext/src/simd/mod.rs b/crates/stdext/src/simd/mod.rs index fe75c457da4..c90eacda375 100644 --- a/crates/stdext/src/simd/mod.rs +++ b/crates/stdext/src/simd/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. mod memset; diff --git a/crates/stdext/src/sys/mod.rs b/crates/stdext/src/sys/mod.rs index 91d59881248..048a1092b75 100644 --- a/crates/stdext/src/sys/mod.rs +++ b/crates/stdext/src/sys/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Platform abstractions. diff --git a/crates/stdext/src/sys/unix.rs b/crates/stdext/src/sys/unix.rs index 50f57463f5e..0f804d6a507 100644 --- a/crates/stdext/src/sys/unix.rs +++ b/crates/stdext/src/sys/unix.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::ffi::c_int; diff --git a/crates/stdext/src/sys/windows.rs b/crates/stdext/src/sys/windows.rs index 59282bec9ff..c666c24eb77 100644 --- a/crates/stdext/src/sys/windows.rs +++ b/crates/stdext/src/sys/windows.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::io; diff --git a/crates/stdext/src/unicode/mod.rs b/crates/stdext/src/unicode/mod.rs index 4722eb7ae99..eb2334d01b0 100644 --- a/crates/stdext/src/unicode/mod.rs +++ b/crates/stdext/src/unicode/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. //! Everything related to Unicode lives here. diff --git a/crates/stdext/src/unicode/utf8.rs b/crates/stdext/src/unicode/utf8.rs index e3b9eecc3aa..5fb4135b270 100644 --- a/crates/stdext/src/unicode/utf8.rs +++ b/crates/stdext/src/unicode/utf8.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. use std::{hint, iter}; diff --git a/crates/unicode-gen/src/main.rs b/crates/unicode-gen/src/main.rs index 60f248b2b08..f9dce423476 100644 --- a/crates/unicode-gen/src/main.rs +++ b/crates/unicode-gen/src/main.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. mod rules; diff --git a/crates/unicode-gen/src/rules.rs b/crates/unicode-gen/src/rules.rs index 7ddadb3e92d..b1f32864097 100644 --- a/crates/unicode-gen/src/rules.rs +++ b/crates/unicode-gen/src/rules.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. +// Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. // Licensed under the MIT License. // Used as an indicator in our rules for ÷ ("does not join"). diff --git a/deploy-local.ps1 b/deploy-local.ps1 new file mode 100644 index 00000000000..a3980ea5176 --- /dev/null +++ b/deploy-local.ps1 @@ -0,0 +1,343 @@ +# Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +# Licensed under the MIT License. + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$RequiredPowerShellVersion = [version]'7.6.4' +if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion -lt $RequiredPowerShellVersion) { + throw "Run deployment with PowerShell $RequiredPowerShellVersion or later (pwsh.exe)." +} +$RepoRoot = $PSScriptRoot +$SourceDir = Join-Path $RepoRoot 'target\deploy\msedit-tzk' +$SourceExe = Join-Path $SourceDir 'edit.exe' +$SourceLicense = Join-Path $SourceDir 'LICENSE.txt' +$InstallDir = Join-Path $env:ProgramFiles 'msedit-tzk' +$InstallExe = Join-Path $InstallDir 'edit.exe' +$AliasExe = Join-Path $InstallDir 'msedit-tzk.exe' +$InstallLicense = Join-Path $InstallDir 'LICENSE.txt' +$ResultPath = Join-Path $RepoRoot 'target\deploy\deployment-result.json' +$SupportedExtensions = @('.txt', '.md', '.log') + +function Get-UserChoiceProgId { + param([string]$Extension) + + $key = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\$Extension\UserChoice" + if (Test-Path -LiteralPath $key) { + return (Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue).ProgId + } + return $null +} + +function ConvertFrom-VersionLines { + # EN: Deployment accepts only the executable's two-line version contract. + # 中文:部署程序僅接受執行檔所定義的雙列版本格式。 + param([Parameter(Mandatory)][string[]]$Lines) + + if ($Lines.Count -ne 2) { + throw "Expected two version rows, received $($Lines.Count)." + } + if ($Lines[0] -notmatch '^edit original version: (\d+\.\d+\.\d+)$') { + throw "Unexpected original version declaration: $($Lines[0])" + } + $originalVersion = $Matches[1] + if ($Lines[1] -notmatch '^tzk version: (\d+\.\d+\.\d+)$') { + throw "Unexpected tzk version declaration: $($Lines[1])" + } + + [pscustomobject]@{ + OriginalVersion = $originalVersion + TzkVersion = $Matches[1] + Lines = $Lines + } +} + +function Get-VersionInfo { + param([Parameter(Mandatory)][string]$Path) + + $lines = @(& $Path --version) + if ($LASTEXITCODE -ne 0) { + throw "Unable to read version declaration: $Path" + } + ConvertFrom-VersionLines -Lines $lines +} + +function Write-Result { + param( + [bool]$Success, + [string]$Message, + [hashtable]$Details = @{} + ) + + $result = [ordered]@{ + success = $Success + message = $Message + timestamp = (Get-Date).ToString('o') + } + foreach ($entry in $Details.GetEnumerator()) { + $result[$entry.Key] = $entry.Value + } + + $result | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $ResultPath -Encoding utf8 +} + +try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Program Files deployment requires an elevated PowerShell session.' + } + + if (-not (Test-Path -LiteralPath $SourceExe)) { + throw "Source executable not found: $SourceExe" + } + if (-not (Test-Path -LiteralPath $SourceLicense)) { + throw "Source license not found: $SourceLicense" + } + + # EN: Deployment preserves and verifies both source and tzk version rows. + # 中文:部署程序分別保存並驗證原始版本與 tzk 版本兩列宣告。 + $BuildVersion = Get-VersionInfo -Path $SourceExe + + Write-Host '=== msedit-tzk local deployment ===' -ForegroundColor Cyan + Write-Host "Original version : $($BuildVersion.OriginalVersion)" + Write-Host "tzk version : $($BuildVersion.TzkVersion)" + Write-Host "Install path : $InstallDir" + + $DefaultHandlersBefore = @{} + foreach ($extension in $SupportedExtensions) { + $DefaultHandlersBefore[$extension] = Get-UserChoiceProgId $extension + } + + if ([IO.Path]::GetFullPath($InstallDir) -ne 'C:\Program Files\msedit-tzk') { + throw "Unexpected install target: $InstallDir" + } + + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Copy-Item -LiteralPath $SourceExe -Destination $InstallExe -Force + Copy-Item -LiteralPath $SourceExe -Destination $AliasExe -Force + Copy-Item -LiteralPath $SourceLicense -Destination $InstallLicense -Force + + $SourceHash = (Get-FileHash -LiteralPath $SourceExe -Algorithm SHA256).Hash + $EditHash = (Get-FileHash -LiteralPath $InstallExe -Algorithm SHA256).Hash + $AliasHash = (Get-FileHash -LiteralPath $AliasExe -Algorithm SHA256).Hash + if ($SourceHash -ne $EditHash -or $SourceHash -ne $AliasHash) { + throw 'Installed executable hash mismatch.' + } + + $Desktop = [Environment]::GetFolderPath('Desktop') + $Programs = [Environment]::GetFolderPath('Programs') + $ProgramGroup = Join-Path $Programs 'msedit-tzk' + $DesktopShortcut = Join-Path $Desktop 'msedit-tzk.lnk' + $MenuShortcut = Join-Path $ProgramGroup 'msedit-tzk.lnk' + New-Item -ItemType Directory -Path $ProgramGroup -Force | Out-Null + + $shell = New-Object -ComObject WScript.Shell + foreach ($shortcutPath in @($DesktopShortcut, $MenuShortcut)) { + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $InstallExe + $shortcut.WorkingDirectory = $InstallDir + $shortcut.IconLocation = "$InstallExe,0" + $shortcut.Description = "msedit-tzk - tzk $($BuildVersion.TzkVersion)" + $shortcut.Save() + } + + [Environment]::SetEnvironmentVariable('MSEDIT_TZK_HOME', $InstallDir, 'User') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $pathEntries = @($userPath -split ';' | Where-Object { $_ }) + if ($pathEntries -notcontains $InstallDir) { + $userPath = (@($pathEntries) + $InstallDir) -join ';' + [Environment]::SetEnvironmentVariable('Path', $userPath, 'User') + } + + $env:MSEDIT_TZK_HOME = $InstallDir + if (($env:Path -split ';') -notcontains $InstallDir) { + $env:Path = "$env:Path;$InstallDir" + } + + foreach ($exeName in @('edit.exe', 'msedit-tzk.exe')) { + $appPathKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\$exeName" + New-Item -Path $appPathKey -Force | Out-Null + Set-Item -Path $appPathKey -Value (Join-Path $InstallDir $exeName) + New-ItemProperty -Path $appPathKey -Name 'Path' -Value $InstallDir -PropertyType String -Force | + Out-Null + } + + # EN: Register Open With candidates without changing any UserChoice default handler. + # 中文:只登錄「開啟方式」候選程式,不修改任何 UserChoice 預設處理程式。 + $ApplicationKey = 'HKCU:\Software\Classes\Applications\edit.exe' + $SupportedTypesKey = Join-Path $ApplicationKey 'SupportedTypes' + $ApplicationCommandKey = Join-Path $ApplicationKey 'shell\open\command' + $ApplicationIconKey = Join-Path $ApplicationKey 'DefaultIcon' + New-Item -Path $SupportedTypesKey -Force | Out-Null + New-Item -Path $ApplicationCommandKey -Force | Out-Null + New-Item -Path $ApplicationIconKey -Force | Out-Null + New-ItemProperty -Path $ApplicationKey -Name 'FriendlyAppName' -Value 'msedit-tzk' -PropertyType String -Force | + Out-Null + Set-Item -Path $ApplicationCommandKey -Value "`"$InstallExe`" `"%1`"" + Set-Item -Path $ApplicationIconKey -Value "$InstallExe,0" + foreach ($extension in $SupportedExtensions) { + New-ItemProperty -Path $SupportedTypesKey -Name $extension -Value '' -PropertyType String -Force | + Out-Null + } + + $ProgId = 'msedit-tzk.Document' + $ProgIdKey = "HKCU:\Software\Classes\$ProgId" + $ProgIdCommandKey = Join-Path $ProgIdKey 'shell\open\command' + $ProgIdIconKey = Join-Path $ProgIdKey 'DefaultIcon' + New-Item -Path $ProgIdCommandKey -Force | Out-Null + New-Item -Path $ProgIdIconKey -Force | Out-Null + Set-Item -Path $ProgIdKey -Value 'Text document (msedit-tzk)' + Set-Item -Path $ProgIdCommandKey -Value "`"$InstallExe`" `"%1`"" + Set-Item -Path $ProgIdIconKey -Value "$InstallExe,0" + + foreach ($extension in $SupportedExtensions) { + $openWithSubKey = "Software\Classes\$extension\OpenWithProgids" + $openWithKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($openWithSubKey) + try { + $openWithKey.SetValue( + $ProgId, + [byte[]]::new(0), + [Microsoft.Win32.RegistryValueKind]::None + ) + } finally { + $openWithKey.Dispose() + } + } + + if (-not ('EnvironmentBroadcast' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class EnvironmentBroadcast { + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint message, UIntPtr wParam, string lParam, + uint flags, uint timeout, out UIntPtr result); +} +'@ + } + $broadcastResult = [UIntPtr]::Zero + [void][EnvironmentBroadcast]::SendMessageTimeout( + [IntPtr]0xffff, + 0x001a, + [UIntPtr]::Zero, + 'Environment', + 0x0002, + 5000, + [ref]$broadcastResult + ) + + if (-not ('ShellAssociationBroadcast' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class ShellAssociationBroadcast { + [DllImport("shell32.dll")] + public static extern void SHChangeNotify( + uint eventId, uint flags, IntPtr item1, IntPtr item2); +} +'@ + } + [ShellAssociationBroadcast]::SHChangeNotify( + 0x08000000, + 0, + [IntPtr]::Zero, + [IntPtr]::Zero + ) + + $InstalledVersion = Get-VersionInfo -Path $InstallExe + $AliasVersion = Get-VersionInfo -Path $AliasExe + $CmdVersion = ConvertFrom-VersionLines -Lines @(cmd.exe /d /c 'edit --version') + $PowerShellCommand = (Get-Command edit.exe -ErrorAction Stop).Source + + if ($InstalledVersion.OriginalVersion -ne $BuildVersion.OriginalVersion -or + $InstalledVersion.TzkVersion -ne $BuildVersion.TzkVersion -or + $AliasVersion.OriginalVersion -ne $BuildVersion.OriginalVersion -or + $AliasVersion.TzkVersion -ne $BuildVersion.TzkVersion) { + throw 'Installed version declaration mismatch.' + } + if ($CmdVersion.OriginalVersion -ne $BuildVersion.OriginalVersion -or + $CmdVersion.TzkVersion -ne $BuildVersion.TzkVersion) { + throw 'CMD command version declaration mismatch.' + } + if ($PowerShellCommand -ne $InstallExe) { + throw "PowerShell resolved an unexpected executable: $PowerShellCommand" + } + if (-not (Test-Path -LiteralPath $DesktopShortcut)) { + throw "Desktop shortcut missing: $DesktopShortcut" + } + if (-not (Test-Path -LiteralPath $MenuShortcut)) { + throw "Start menu shortcut missing: $MenuShortcut" + } + + foreach ($shortcutPath in @($DesktopShortcut, $MenuShortcut)) { + $shortcut = $shell.CreateShortcut($shortcutPath) + if ($shortcut.TargetPath -ne $InstallExe) { + throw "Shortcut target mismatch: $shortcutPath" + } + } + + $ExpectedOpenCommand = "`"$InstallExe`" `"%1`"" + $ActualOpenCommand = (Get-Item -LiteralPath $ApplicationCommandKey).GetValue('') + if ($ActualOpenCommand -ne $ExpectedOpenCommand) { + throw "Open With command mismatch: $ActualOpenCommand" + } + $SupportedTypeNames = (Get-Item -LiteralPath $SupportedTypesKey).GetValueNames() + foreach ($extension in $SupportedExtensions) { + if ($SupportedTypeNames -notcontains $extension) { + throw "SupportedTypes entry missing: $extension" + } + + $openWithSubKey = "Software\Classes\$extension\OpenWithProgids" + $openWithKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($openWithSubKey) + try { + if ($null -eq $openWithKey -or $openWithKey.GetValueNames() -notcontains $ProgId) { + throw "OpenWithProgids entry missing: $extension" + } + } finally { + if ($null -ne $openWithKey) { + $openWithKey.Dispose() + } + } + + $DefaultHandlerAfter = Get-UserChoiceProgId $extension + if ($DefaultHandlerAfter -ne $DefaultHandlersBefore[$extension]) { + throw "Default application changed unexpectedly: $extension" + } + } + + $details = @{ + powerShellVersion = $PSVersionTable.PSVersion.ToString() + originalVersion = $InstalledVersion.OriginalVersion + tzkVersion = $InstalledVersion.TzkVersion + versionDeclaration = $InstalledVersion.Lines + installDir = $InstallDir + editExe = $InstallExe + aliasExe = $AliasExe + sha256 = $EditHash + desktopShortcut = $DesktopShortcut + startMenuShortcut = $MenuShortcut + userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + mseditTzkHome = [Environment]::GetEnvironmentVariable('MSEDIT_TZK_HOME', 'User') + cmdVersionDeclaration = $CmdVersion.Lines + powershellCommand = $PowerShellCommand + openWithExtensions = $SupportedExtensions + fileAssociationCommand = $ActualOpenCommand + defaultHandlersUnchanged = $true + } + Write-Result -Success $true -Message 'Deployment completed successfully.' -Details $details + + Write-Host "Installed original version: $($InstalledVersion.OriginalVersion)" -ForegroundColor Green + Write-Host "Installed tzk version : $($InstalledVersion.TzkVersion)" -ForegroundColor Green + Write-Host 'Deployment completed successfully.' -ForegroundColor Green + Start-Sleep -Seconds 3 + exit 0 +} catch { + $message = $_.Exception.Message + Write-Result -Success $false -Message $message + Write-Host "Deployment failed: $message" -ForegroundColor Red + Start-Sleep -Seconds 8 + exit 1 +} diff --git a/deploy-wsl.sh b/deploy-wsl.sh new file mode 100644 index 00000000000..f6518148986 --- /dev/null +++ b/deploy-wsl.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Modifications Copyright (c) 2026 KOSMOS, Tzushih.K. +# Licensed under the MIT License. + +set -Eeuo pipefail + +SCRIPT_PATH="$(readlink -f -- "${BASH_SOURCE[0]}")" +if [[ "${1:-}" == '--root' ]]; then + REPO_ROOT="$2" + SHARED_ROOT="$3" +else + REPO_ROOT="${MSEDIT_TZK_REPO_ROOT:-$(cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)}" + SHARED_ROOT="${MSEDIT_TZK_WSL_SHARED_ROOT:-/mnt/wsl-mount}" +fi + +TARGET_DIR="${MSEDIT_TZK_WSL_TARGET_DIR:-$SHARED_ROOT/msedit-tzk-target}" +SOURCE_EXE="$TARGET_DIR/release/edit" +DEPLOY_ROOT="$SHARED_ROOT/msedit-tzk-deploy" +DEPLOY_BIN="$DEPLOY_ROOT/bin/edit" + +if [[ ! -x "$SOURCE_EXE" ]]; then + echo "WSL release executable not found: $SOURCE_EXE" >&2 + exit 1 +fi + +if [[ "$EUID" -ne 0 ]]; then + # EN: Warn before sudo requests the user's password in the visible WSL terminal. + # 中文:sudo 在可見的 WSL 終端機要求密碼前,先明確提醒使用者。 + echo 'NOTICE / 提醒:即將執行 sudo,以更新 /usr/local/bin 的 WSL 命令連結。' + echo 'The WSL terminal may request your Ubuntu password. / WSL 終端機可能要求輸入 Ubuntu 密碼。' + exec sudo bash "$SCRIPT_PATH" --root "$REPO_ROOT" "$SHARED_ROOT" +fi + +for command_name in edit msedit msedit-tzk; do + command_path="/usr/local/bin/$command_name" + if [[ -e "$command_path" || -L "$command_path" ]]; then + resolved="$(readlink -f -- "$command_path" || true)" + # EN: -ef recognizes the same inode through /mnt/wsl-mount and /mnt/d/wsl-mount aliases. + # 中文:使用 -ef 辨識 /mnt/wsl-mount 與 /mnt/d/wsl-mount 別名所指向的同一實體檔案。 + if [[ ! -e "$DEPLOY_BIN" || ! "$command_path" -ef "$DEPLOY_BIN" ]]; then + echo "Refusing to replace unrelated command: $command_path -> $resolved" >&2 + exit 1 + fi + fi +done + +echo '=== msedit-tzk visible WSL/Ubuntu deployment ===' +echo "Source : $SOURCE_EXE" +echo "Deploy : $DEPLOY_ROOT" + +install -d -m 0755 "$DEPLOY_ROOT/bin" +install -m 0755 "$SOURCE_EXE" "$DEPLOY_BIN" +install -m 0644 "$REPO_ROOT/LICENSE" "$DEPLOY_ROOT/LICENSE.txt" +ln -sfn edit "$DEPLOY_ROOT/bin/msedit" +ln -sfn edit "$DEPLOY_ROOT/bin/msedit-tzk" +for command_name in edit msedit msedit-tzk; do + ln -sfn "$DEPLOY_BIN" "/usr/local/bin/$command_name" +done + +SOURCE_HASH="$(sha256sum "$SOURCE_EXE" | awk '{print $1}')" +DEPLOY_HASH="$(sha256sum "$DEPLOY_BIN" | awk '{print $1}')" +if [[ "$SOURCE_HASH" != "$DEPLOY_HASH" ]]; then + echo 'WSL deployed executable hash mismatch.' >&2 + exit 1 +fi + +"$DEPLOY_BIN" --version +echo "SHA-256: $DEPLOY_HASH" +for command_name in edit msedit msedit-tzk; do + echo "$command_name -> $(readlink -f -- "/usr/local/bin/$command_name")" +done +echo 'WSL deployment completed successfully.' diff --git a/i18n/edit.toml b/i18n/edit.toml index 6b4f9ddd2cd..66962d2c1d5 100644 --- a/i18n/edit.toml +++ b/i18n/edit.toml @@ -340,6 +340,16 @@ vi = "Mở tệp…" zh-hans = "打开文件…" zh-hant = "開啟舊檔…" +[FileOpenRecentFiles] +en = "Recent files (last 5; click to open)" +zh-hans = "最近打开的文件(最多5笔,单击打开)" +zh-hant = "最近開啟的檔案(最多5筆,單擊開啟)" + +[FileOpenRecentEmpty] +en = "No recently opened files" +zh-hans = "没有最近打开的文件" +zh-hant = "沒有最近開啟的檔案" + [FileSave] en = "Save" ar = "حفظ" @@ -427,6 +437,11 @@ tr = "Tercihler" zh-hans = "设置" zh-hant = "設定" +[FileTheme] +en = "Theme…" +zh-hans = "主题…" +zh-hant = "主題…" + [FileClose] en = "Close File" ar = "إغلاق الملف" @@ -838,6 +853,28 @@ vi = "Chọn tất cả" zh-hans = "全选" zh-hant = "全選" +# EN: msedit-tzk hard boundary alignment. +# 中文:msedit-tzk 邊界對齊功能。 +[EditBoundaryAlign] +en = "Boundary Alignment…" +zh-hans = "边界对齐…" +zh-hant = "邊界對齊…" + +[BoundaryAlignDialogTitle] +en = "Boundary Alignment" +zh-hans = "边界对齐" +zh-hant = "邊界對齊" + +[BoundaryAlignDescription] +en = "Right boundary column (positive integer):" +zh-hans = "右边界字元位置(正整数):" +zh-hant = "右邊界字元位置(正整數):" + +[BoundaryAlignInvalid] +en = "Enter a positive integer." +zh-hans = "请输入正整数。" +zh-hant = "請輸入正整數。" + # A menu bar item [View] en = "View" @@ -874,6 +911,28 @@ vi = "Xem" zh-hans = "视图" zh-hant = "檢視" +# EN: msedit-tzk Markdown navigation pane. +# 中文:msedit-tzk Markdown 導覽視窗。 +[ViewNavigation] +en = "Navigation Pane…" +zh-hans = "导航窗口…" +zh-hant = "導覽視窗…" + +[NavigationDialogTitle] +en = "[Navigation Pane]" +zh-hans = "[导航窗口]" +zh-hant = "[導覽視窗]" + +[NavigationNoContent] +en = "No headings or text" +zh-hans = "没有标题或文字" +zh-hant = "沒有標題或文字" + +[NavigationEscapeHint] +en = "ESC Cancel" +zh-hans = "ESC取消" +zh-hant = "ESC取消" + [ViewFocusStatusbar] en = "Focus Statusbar" ar = "تركيز شريط الحالة" @@ -1077,7 +1136,7 @@ tr = "Kaydedilmemiş Değişiklikler" uk = "Незбережені зміни" vi = "Các thay đổi chưa được lưu" zh-hans = "未保存的更改" -zh-hant = "有未儲存的變更" +zh-hant = "有尚未儲存的變更" [UnsavedChangesDialogDescription] en = "Do you want to save the changes you made?" @@ -1181,7 +1240,7 @@ tr = "Kaydetme" uk = "Не зберігати" vi = "Không lưu" zh-hans = "不保存" -zh-hant = "不儲存" +zh-hant = "放棄儲存" [AboutDialogTitle] en = "About" @@ -1218,8 +1277,13 @@ vi = "Giới thiệu" zh-hans = "关于" zh-hant = "關於" -[AboutDialogVersion] -en = "Version: " +[ThemeDialogTitle] +en = "Select Theme" +zh-hans = "选择主题" +zh-hant = "選擇主題" + +[AboutDialogOriginalVersion] +en = "Original version: " ar = "الإصدار: " bn = "সংস্করণ: " cs = "Verze: " @@ -1250,8 +1314,13 @@ tk = "Wersiýa: " tr = "Sürüm: " uk = "Версія: " vi = "Phiên bản: " -zh-hans = "版本: " -zh-hant = "版本: " +zh-hans = "原始版本: " +zh-hant = "原始版本: " + +[AboutDialogTzkVersion] +en = "tzk version: " +zh-hans = "tzk 版本: " +zh-hant = "tzk 版本: " # Shown when the clipboard size exceeds the limit for OSC 52 [LargeClipboardWarningLine1] @@ -1395,7 +1464,7 @@ tr = "Kopyaladığınız metin paylaşmak için çok büyük." uk = "Скопійований текст завеликий для передачі." vi = "Văn bản bạn sao chép quá lớn, không thể chia sẻ." zh-hans = "你复制的文本过大,无法共享。" -zh-hant = "您複製的文字太大,無法分享。" +zh-hant = "您複製的文字太多,無法分享。" [WarningDialogTitle] en = "Warning" @@ -1644,7 +1713,7 @@ tr = "Tam sözcük" uk = "Ціле слово" vi = "Khớp toàn bộ từ" zh-hans = "全字匹配" -zh-hant = "比對整個字" +zh-hant = "比對完整字詞" # Toggle [SearchUseRegex] @@ -1680,7 +1749,7 @@ tr = "RegEx kullan" uk = "RegEx" vi = "Sử dụng biểu thức chính quy" zh-hans = "正则" -zh-hant = "使用正規表達式" +zh-hant = "使用正規表示式" # Button [SearchReplaceAll] @@ -2066,4 +2135,4 @@ tr = "Dosya zaten var. Üzerine yazmak istiyor musunuz?" uk = "Файл вже існує. Перезаписати?" vi = "Tệp đã tồn tại. Bạn có muốn ghi đè không?" zh-hans = "文件已存在。要覆盖它吗?" -zh-hant = "檔案已存在。要覆蓋它嗎?" +zh-hant = "檔案已存在。要覆寫嗎?" diff --git a/setuplocal.md b/setuplocal.md new file mode 100644 index 00000000000..e35d2a7134c --- /dev/null +++ b/setuplocal.md @@ -0,0 +1,309 @@ +# msedit-tzk 本機開發部署規則 + +本文件定義 Windows 與 WSL/Ubuntu 兩個本機原生版本的預設建置部署方式。所有建置與暫存產物必須留在資料碟;只有 Windows/Ubuntu 的 Rust 工具鏈、系統整合項目、`Program Files` 及 `/usr/local/bin` 命令連結可以位於系統碟。 + +## 本次部署基準 + +- 原始版本:`2.0.0` +- tzk 版本:`0.0.8` +- 原始碼目錄:`D:\CodexWorkspace\msedit` +- Windows 安裝目錄:`C:\Program Files\msedit-tzk` +- WSL 共用掛載:`D:\wsl-mount` → `/mnt/wsl-mount` +- WSL release:`/mnt/wsl-mount/msedit-tzk-target/release/edit` +- WSL 安裝目錄:`/mnt/wsl-mount/msedit-tzk-deploy` +- WSL 發行版:`Ubuntu-26.04` +- 主要命令:`edit` +- 別名命令:`msedit-tzk` +- 使用者設定:`%APPDATA%\TZK\Edit\settings.json` + +## 預設部署入口 + +未來本地開發的標準入口為 repository 根目錄的 `build-deploy-all.ps1`。每次正式建置部署都必須同時完成 Windows 與 WSL/Ubuntu 原生版本,不得只以 Windows 的 Linux cross-check 取代 WSL 原生測試。`build-local.ps1`、`deploy-local.ps1`、`build-wsl.sh`、`deploy-wsl.sh` 僅供單一平台診斷或由總入口呼叫。 + +總入口必須使用 PowerShell 7.6.4 (`pwsh.exe`) 啟動獨立、可見的終端機。Windows Cargo 與 WSL Cargo 的格式檢查、測試、ICU 測試、release 編譯、版本、SHA-256 及部署過程都必須直接顯示,不得以背景或隱藏視窗處理。Windows 顯示 UAC 前、WSL 執行 sudo 前,必須先以中英文字樣提醒使用者;不得停用 UAC、免除 sudo 密碼或繞過系統權限模型。 + +```powershell +$RepoRoot = 'D:\CodexWorkspace\msedit' +Set-Location -LiteralPath $RepoRoot + +& .\build-deploy-all.ps1 +if ($LASTEXITCODE -ne 0) { throw "Dual-platform workflow failed with exit code $LASTEXITCODE." } +``` + +Windows UAC 視窗必須顯示來源版本、安裝目錄、安裝後版本與完成訊息;WSL 終端機必須顯示 Ubuntu、Linux Rust/Cargo、部署路徑及三個命令連結。總流程紀錄寫入 `target\build\build-deploy-all-result.json`,Windows 部署紀錄寫入 `target\deploy\deployment-result.json`,WSL 建置紀錄寫入 `/mnt/wsl-mount/msedit-tzk-build/build-result.txt`。 + +## 必要及選用檔案 + +必要部署檔: + +| 來源 | 部署位置 | 用途 | +|---|---|---| +| `target\release\edit.exe` | `edit.exe` | 主要執行檔;圖示、manifest、語系及語法定義均已內嵌 | +| `target\release\edit.exe` | `msedit-tzk.exe` | 命令別名,內容與 `edit.exe` 相同 | +| `LICENSE` | `LICENSE.txt` | MIT 授權聲明,發佈時必須隨附 | + +WSL 必要部署檔: + +| 來源 | 部署位置 | 用途 | +|---|---|---| +| `/mnt/wsl-mount/msedit-tzk-target/release/edit` | `/mnt/wsl-mount/msedit-tzk-deploy/bin/edit` | Ubuntu 原生 ELF 執行檔 | +| `LICENSE` | `/mnt/wsl-mount/msedit-tzk-deploy/LICENSE.txt` | MIT 授權聲明 | +| WSL 部署執行檔 | `/usr/local/bin/edit`、`msedit`、`msedit-tzk` | 指向共用資料碟執行檔的命令連結 | + +`deploy-local.ps1` 是開發與部署自動化檔,必須保留在原始碼 repository,但不需複製到 `Program Files` 執行環境。 + +不需部署: + +- `edit.pdb`:僅供除錯,需要診斷 crash 時才另外複製。 +- `*.d`、`*.rlib`、Cargo lock files:建置中間產物。 +- `assets\edit.ico` 與 `edit.exe.manifest`:release 建置時已內嵌於 EXE。 +- `icuuc.dll`、`icuin.dll`、`VCRUNTIME140.dll` 及 Windows API DLL:本機已由 Windows/VC Runtime 提供;部署前仍須執行 ICU 測試確認。 + +## 權限及安全規則 + +1. 寫入 `C:\Program Files` 前必須確認目前 PowerShell 是以系統管理員身分執行;不得關閉 UAC、修改 ACL 或以其他方式繞過權限。 +2. 安裝目標必須解析成精確的 `C:\Program Files\msedit-tzk`,不得對 `C:\Program Files` 或磁碟根目錄做遞迴刪除。 +3. 部署前先完成建置及所有驗證;驗證失敗不得更新正式安裝內容。 +4. PATH 與登錄項目必須採冪等更新,不得重複加入相同目錄,也不得覆蓋其他既有 PATH 項目。 +5. 現有使用者設定 `%APPDATA%\TZK\Edit\settings.json` 不屬於部署檔,不得覆蓋或刪除。 +6. WSL 寫入 `/usr/local/bin` 前必須在可見終端機提醒 sudo,且不得覆蓋指向其他程式的既有命令。 +7. WSL release 與部署實體檔必須位於 `/mnt/wsl-mount`;`/usr/local/bin` 僅保存命令連結。 + +## Windows 標準建置與版本宣告 + +在一般權限 PowerShell 中進入 repository 根目錄後執行;此階段不需 UAC。公開入口會自動開啟獨立可見的編譯視窗,內部 `-Child` 參數只供腳本自行使用,不得在標準流程中直接傳入: + +```powershell +$RepoRoot = (Resolve-Path -LiteralPath '.').Path +$BuildExe = Join-Path $RepoRoot 'target\release\edit.exe' +$InstallDir = Join-Path $env:ProgramFiles 'msedit-tzk' + +& (Join-Path $RepoRoot 'build-local.ps1') +if ($LASTEXITCODE -ne 0) { throw "Visible build failed with exit code $LASTEXITCODE." } + +$BuildVersion = @(& $BuildExe --version) +if ($LASTEXITCODE -ne 0 -or $BuildVersion.Count -ne 2) { + throw "Unexpected two-line version declaration: $($BuildVersion -join '; ')" +} +if ($BuildVersion[0] -notmatch '^edit original version: (\d+\.\d+\.\d+)$') { + throw "Unexpected original version declaration: $($BuildVersion[0])" +} +$OriginalVersion = $Matches[1] +if ($BuildVersion[1] -notmatch '^tzk version: (\d+\.\d+\.\d+)$') { + throw "Unexpected tzk version declaration: $($BuildVersion[1])" +} +$TzkVersion = $Matches[1] +$BuildVersionDeclaration = $BuildVersion -join "`n" + +Write-Host '=== msedit-tzk local deployment ===' +Write-Host "Original version : $OriginalVersion" +Write-Host "tzk version : $TzkVersion" +Write-Host "Install path : $InstallDir" +``` + +版本宣告必須以「原始版本」與「tzk 版本」兩列在任何系統寫入前顯示。不得部署無法回報雙列版本或版本格式不符的 EXE。 + +## Windows 標準部署內容 + +```powershell +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Program Files deployment requires an elevated PowerShell session.' +} + +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +Copy-Item -LiteralPath $BuildExe -Destination (Join-Path $InstallDir 'edit.exe') -Force +Copy-Item -LiteralPath $BuildExe -Destination (Join-Path $InstallDir 'msedit-tzk.exe') -Force +Copy-Item -LiteralPath (Join-Path $RepoRoot 'LICENSE') -Destination (Join-Path $InstallDir 'LICENSE.txt') -Force +``` + +部署後必須比較來源與安裝檔 SHA-256: + +```powershell +$sourceHash = (Get-FileHash -LiteralPath $BuildExe -Algorithm SHA256).Hash +$editHash = (Get-FileHash -LiteralPath (Join-Path $InstallDir 'edit.exe') -Algorithm SHA256).Hash +$aliasHash = (Get-FileHash -LiteralPath (Join-Path $InstallDir 'msedit-tzk.exe') -Algorithm SHA256).Hash +if ($sourceHash -ne $editHash -or $sourceHash -ne $aliasHash) { + throw 'Installed executable hash mismatch.' +} +``` + +## 桌面及開始功能表 + +建立兩個捷徑: + +- `%USERPROFILE%\OneDrive\桌面\msedit-tzk.lnk`,實際位置須由 `GetFolderPath('Desktop')` 取得。 +- `%APPDATA%\Microsoft\Windows\Start Menu\Programs\msedit-tzk\msedit-tzk.lnk`,實際程式集位置須由 `GetFolderPath('Programs')` 取得。 + +```powershell +$TargetExe = Join-Path $InstallDir 'edit.exe' +$Desktop = [Environment]::GetFolderPath('Desktop') +$Programs = [Environment]::GetFolderPath('Programs') +$ProgramGroup = Join-Path $Programs 'msedit-tzk' +New-Item -ItemType Directory -Path $ProgramGroup -Force | Out-Null + +$shell = New-Object -ComObject WScript.Shell +foreach ($shortcutPath in @( + (Join-Path $Desktop 'msedit-tzk.lnk'), + (Join-Path $ProgramGroup 'msedit-tzk.lnk') +)) { + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $TargetExe + $shortcut.WorkingDirectory = $InstallDir + $shortcut.IconLocation = "$TargetExe,0" + $shortcut.Description = "msedit-tzk - tzk $TzkVersion" + $shortcut.Save() +} +``` + +## 環境變數及 GUI 呼叫 + +使用者環境變數: + +- `MSEDIT_TZK_HOME=C:\Program Files\msedit-tzk` +- 使用者 `PATH` 加入 `C:\Program Files\msedit-tzk` + +```powershell +[Environment]::SetEnvironmentVariable('MSEDIT_TZK_HOME', $InstallDir, 'User') + +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$pathEntries = @($userPath -split ';' | Where-Object { $_ }) +if ($pathEntries -notcontains $InstallDir) { + $newUserPath = (@($pathEntries) + $InstallDir) -join ';' + [Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User') +} + +$env:MSEDIT_TZK_HOME = $InstallDir +if (($env:Path -split ';') -notcontains $InstallDir) { + $env:Path = "$env:Path;$InstallDir" +} +``` + +為了讓 Windows GUI 的「執行」及 ShellExecute 能直接找到程式,建立目前使用者的 App Paths: + +```powershell +foreach ($exeName in @('edit.exe', 'msedit-tzk.exe')) { + $appPathKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\$exeName" + New-Item -Path $appPathKey -Force | Out-Null + Set-Item -Path $appPathKey -Value (Join-Path $InstallDir $exeName) + New-ItemProperty -Path $appPathKey -Name 'Path' -Value $InstallDir -PropertyType String -Force | Out-Null +} +``` + +## 開啟檔案候選程式 + +`msedit-tzk` 必須列入 `.txt`、`.md`、`.log` 的 Windows「開啟檔案」/「選擇其他應用程式」候選清單,但不得設為預設應用程式。 + +部署腳本必須建立: + +- `HKCU\Software\Classes\Applications\edit.exe\SupportedTypes` +- `HKCU\Software\Classes\Applications\edit.exe\shell\open\command` +- `HKCU\Software\Classes\msedit-tzk.Document` +- `HKCU\Software\Classes\.\OpenWithProgids` 中的 `msedit-tzk.Document` + +呼叫命令固定為: + +```text +"C:\Program Files\msedit-tzk\edit.exe" "%1" +``` + +部署前後必須比對以下 `UserChoice\ProgId`,並確認值完全沒有變動: + +```text +HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.\UserChoice +``` + +登錄完成後呼叫 `SHChangeNotify(SHCNE_ASSOCCHANGED)` 更新 Windows Shell 的候選程式快取。 + +完成登錄後必須廣播環境變數變更,讓 Explorer 後續啟動的 GUI、CMD 與 PowerShell 繼承新 PATH: + +```powershell +if (-not ('EnvironmentBroadcast' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class EnvironmentBroadcast { + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint message, UIntPtr wParam, string lParam, + uint flags, uint timeout, out UIntPtr result); +} +'@ +} + +$broadcastResult = [UIntPtr]::Zero +[void][EnvironmentBroadcast]::SendMessageTimeout( + [IntPtr]0xffff, 0x001a, [UIntPtr]::Zero, 'Environment', + 0x0002, 5000, [ref]$broadcastResult +) +``` + +當前部署 PowerShell 使用上面的 `$env:Path` 即時更新;已經開啟的其他終端機仍須關閉後重新開啟。 + +## Windows 部署後驗證 + +下列檢查全部成功後才視為部署完成: + +```powershell +$InstalledVersion = @(& (Join-Path $InstallDir 'edit.exe') --version) -join "`n" +$AliasVersion = @(& (Join-Path $InstallDir 'msedit-tzk.exe') --version) -join "`n" +$CmdVersion = @(cmd.exe /d /c 'edit --version') -join "`n" +$PowerShellCommand = (Get-Command edit.exe -ErrorAction Stop).Source + +if ($InstalledVersion -ne $BuildVersionDeclaration -or $AliasVersion -ne $BuildVersionDeclaration) { + throw 'Installed version declaration mismatch.' +} +if ($CmdVersion -ne $BuildVersionDeclaration) { + throw 'CMD command resolution failed.' +} +if ($PowerShellCommand -ne (Join-Path $InstallDir 'edit.exe')) { + throw "PowerShell resolved an unexpected executable: $PowerShellCommand" +} + +$DesktopShortcut = Join-Path ([Environment]::GetFolderPath('Desktop')) 'msedit-tzk.lnk' +$MenuShortcut = Join-Path (Join-Path ([Environment]::GetFolderPath('Programs')) 'msedit-tzk') 'msedit-tzk.lnk' +if (-not (Test-Path -LiteralPath $DesktopShortcut) -or -not (Test-Path -LiteralPath $MenuShortcut)) { + throw 'Shortcut verification failed.' +} + +Write-Host "Installed version declaration:`n$InstalledVersion" +Write-Host 'Deployment completed successfully.' +``` + +最終部署紀錄至少必須包含原始版本、tzk 版本、雙列安裝版本宣告、安裝目錄、兩個 EXE 的 SHA-256、桌面捷徑、開始功能表捷徑、PATH、`MSEDIT_TZK_HOME` 及 App Paths 驗證結果。 + +## WSL/Ubuntu 原生建置與部署 + +WSL 必須使用 `Ubuntu-26.04` 內的 Linux Rust toolchain,不得誤用 Windows PATH 中的 `cargo.exe`。建置腳本會確認 `rustc -vV` 的 host 為 `x86_64-unknown-linux-gnu`,並將 Cargo target 指向 `/mnt/wsl-mount/msedit-tzk-target`: + +```bash +cd /mnt/d/CodexWorkspace/msedit +bash ./build-wsl.sh +``` + +`build-wsl.sh` 必須在可見終端機顯示 Ubuntu、Rust、Cargo、repository、共用掛載、格式檢查、workspace 測試、ICU 測試、release 版本及 SHA-256。完整輸出另存於 `/mnt/wsl-mount/msedit-tzk-build/build-transcript.txt`。 + +建置完成後,在同一個可見 WSL 終端機執行: + +```bash +cd /mnt/d/CodexWorkspace/msedit +bash ./deploy-wsl.sh +``` + +腳本在呼叫 sudo 前必須先顯示中英提醒。使用者確認終端機提示後輸入 Ubuntu 密碼;部署程序只可更新 `/mnt/wsl-mount/msedit-tzk-deploy` 及指向該目錄的 `/usr/local/bin/edit`、`msedit`、`msedit-tzk`。若既有命令指向其他程式,部署必須停止,不得覆蓋。 + +部署後必須驗證三個命令皆回報相同的雙列版本,且 release 與部署檔 SHA-256 完全一致: + +```bash +for name in edit msedit msedit-tzk; do + command -v "$name" + "$name" --version +done + +sha256sum \ + /mnt/wsl-mount/msedit-tzk-target/release/edit \ + /mnt/wsl-mount/msedit-tzk-deploy/bin/edit +``` + +WSL 的 `/etc/wsl.conf` 必須保持 `mountFsTab=true`,`/etc/fstab` 則保持 `/mnt/d/wsl-mount` 至 `/mnt/wsl-mount` 的 `bind,nofail` 規則;雙平台總流程開始前應先確認該掛載點可用。