From d15a36d98b534eb770a198f4d86fe27322ef3549 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 17 Sep 2026 23:12:48 +0800 Subject: [PATCH] Fix Markdown horizontal scrolling --- Frameworks/Resources/Localizable.xcstrings | 984 ++++++++++++++++-- TFM/Features/AI/ConversationViews.swift | 4 + TFM/Features/AI/TranscriptNativeRows.swift | 231 +++- TFMTests/TranscriptListViewTests.swift | 161 +++ Termind.xcodeproj/project.pbxproj | 26 +- .../xcschemes/TermindMCPBridge.xcscheme | 77 ++ 6 files changed, 1373 insertions(+), 110 deletions(-) create mode 100644 Termind.xcodeproj/xcshareddata/xcschemes/TermindMCPBridge.xcscheme diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index c716e51c..76c51485 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1,53 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "Import Edited Copy…" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"导入已编辑副本…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"輸入已編輯副本…"}}}}, - "Choose an edited copy with the same file format as the original." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请选择与原文件格式相同的已编辑副本。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請選擇與原始檔案格式相同的已編輯副本。"}}}}, - "Open With" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开方式"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開方式"}}}}, - "Open With…" : {"extractionState":"extracted","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开方式…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開方式…"}}}}, - "Other…" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"其他…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"其他…"}}}}, - "App Store…" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"App Store…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"App Store…"}}}}, - "%@ (default)" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%@(默认)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%@(預設)"}}}}, - "No installed app can open this file." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"没有已安装的应用可以打开此文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"沒有已安裝的App可以打開此檔案。"}}}}, - "Error Details" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"错误详情"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"錯誤詳細資訊"}}}}, - "The remote file changed. Review it before uploading again." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远端文件已更改。请检查后再上传。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"遠端檔案已變更。請檢查後再上傳。"}}}}, - "The original connection is no longer available." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"原连接已不可用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"原連線已無法使用。"}}}}, - "The local file changed while preparing the upload. Try again." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"准备上传时本地文件发生了更改。请重试。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"準備上傳時本機檔案發生了變更。請重試。"}}}}, - "This file cannot be uploaded back to its original path." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法将此文件上传回原路径。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法將此檔案上傳回原路徑。"}}}}, - "The remote file has changed" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远端文件已更改"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"遠端檔案已變更"}}}}, - "Overwrite Remote File" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"覆盖远端文件"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"覆寫遠端檔案"}}}}, - "The remote file changed or could not be verified. Replace it with your saved local changes?" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远端文件已更改或无法验证。要用已保存的本地修改替换它吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"遠端檔案已變更或無法驗證。要用已儲存的本機修改取代它嗎?"}}}}, - "Uploading changes" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在上传修改"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"正在上傳修改"}}}}, - "Reconnect to upload changes" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"重新连接以上传修改"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"重新連線以上傳修改"}}}}, - "Open Local Copy" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开本地副本"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開本機副本"}}}}, - "Keep Local Copy" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"保留本地副本"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"保留本機副本"}}}}, - "Quit with unuploaded changes?" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"仍有修改未上传,确定退出吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"仍有修改未上傳,確定結束嗎?"}}}}, - "Your edited files have not all been uploaded. Pending edits will not be restored next time." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑过的文件尚未全部上传。下次启动时不会恢复待上传记录。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯過的檔案尚未全部上傳。下次啟動時不會還原待上傳記錄。"}}}}, - "Quit Anyway" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"仍然退出"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"仍然結束"}}}}, - "The edited copy uses a different format" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑后的副本使用了不同格式"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯後的副本使用了不同格式"}}}}, - "Save this copy separately to keep the original remote file format." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请单独保存此副本,以保留远端文件的原始格式。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請另外儲存此副本,以保留遠端檔案的原始格式。"}}}}, - "Unable to preserve remote file permissions" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法保留远端文件权限"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法保留遠端檔案權限"}}}}, - "Unable to inspect edited file" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法检查编辑后的文件"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法檢查編輯後的檔案"}}}}, - "Unable to inspect remote edit destination" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法检查远端编辑目标"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法檢查遠端編輯目標"}}}}, - "Google Vertex AI" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Google Vertex AI"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Google Vertex AI"}}}}, - "Vertex AI" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Vertex AI"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Vertex AI"}}}}, - "Project ID" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"项目ID"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"專案ID"}}}}, - "Location" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"区域"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"區域"}}}}, - "Service Account" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"服务账号"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"服務帳戶"}}}}, - "Remove Service Account" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"移除服务账号"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"移除服務帳戶"}}}}, - "Paste Service Account JSON" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"粘贴服务账号JSON"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"貼上服務帳戶JSON"}}}}, - "Enter a valid project and location to configure Vertex AI." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"输入有效的项目和区域以配置Vertex AI。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"輸入有效的專案與區域以設定Vertex AI。"}}}}, - "Vertex AI requires a service account. Add model IDs manually; model discovery is unavailable." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Vertex AI需要服务账号。不支持模型发现,请手动添加模型ID。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Vertex AI需要服務帳戶。不支援模型探索,請手動新增模型ID。"}}}}, - "Enter a valid provider URL, or a Vertex project and location." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请输入有效的提供方URL,或Vertex项目和区域。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請輸入有效的供應商URL,或Vertex專案與區域。"}}}}, - "Choose a valid Google service account JSON file smaller than 1 MiB." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请选择小于1 MiB的有效Google服务账号JSON文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請選擇小於1 MiB的有效Google服務帳戶JSON檔案。"}}}}, - "Show more" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"显示更多"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"顯示更多"}}}}, - "Show less" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"收起"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"收合"}}}}, - "install" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"安装"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"安裝"}}}}, - "skills" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"技能"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"技能"}}}}, - "persistent memory" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"持久化记忆"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"持久化記憶"}}}}, - "saved conversations" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"历史会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史對話"}}}}, - "skill files" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"技能文件"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"技能檔案"}}}}, - "%@ (%lld operations)" : {"extractionState":"manual","localizations":{"en":{"variations":{"plural":{"one":{"stringUnit":{"state":"translated","value":"%1$@ (%2$lld operation)"}},"other":{"stringUnit":{"state":"translated","value":"%1$@ (%2$lld operations)"}}}}},"zh-Hans":{"variations":{"plural":{"other":{"stringUnit":{"state":"translated","value":"%1$@(%2$lld项操作)"}}}}},"zh-Hant":{"variations":{"plural":{"other":{"stringUnit":{"state":"translated","value":"%1$@(%2$lld項操作)"}}}}}}}, "" : { "localizations" : { "zh-Hans" : { @@ -203,6 +156,70 @@ } } }, + "%@ (%lld operations)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$lld operation)" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$lld operations)" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(%2$lld项操作)" + } + } + } + } + }, + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(%2$lld項操作)" + } + } + } + } + } + } + }, + "%@ (default)" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@(默认)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@(預設)" + } + } + } + }, "%@ connected · %@ workspaces" : { "comment" : "A label that shows the number of connected workspaces and the total count of workspaces.", "isCommentAutoGenerated" : true, @@ -278,6 +295,7 @@ } }, "%@ free" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -294,6 +312,7 @@ } }, "%@ free, then %@ per %@. Auto-renews until canceled." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -333,6 +352,7 @@ } }, "%@ per %@. Auto-renews until canceled." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3067,6 +3087,23 @@ } } }, + "App Store…" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store…" + } + } + } + }, "Appearance" : { "localizations" : { "zh-Hans" : { @@ -4049,6 +4086,7 @@ } }, "Buy Lifetime Access" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4633,6 +4671,40 @@ } } }, + "Choose a valid Google service account JSON file smaller than 1 MiB." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请选择小于1 MiB的有效Google服务账号JSON文件。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請選擇小於1 MiB的有效Google服務帳戶JSON檔案。" + } + } + } + }, + "Choose an edited copy with the same file format as the original." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请选择与原文件格式相同的已编辑副本。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請選擇與原始檔案格式相同的已編輯副本。" + } + } + } + }, "Choose annual access or a permanent unlock for multi-pane workspaces, the AI Assistant, terminal uploads, MCP, and iCloud sync." : { "localizations" : { "zh-Hans" : { @@ -6370,6 +6442,22 @@ } } }, + "Copies selected terminal text to the clipboard when you release the mouse button." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "松开鼠标按键时,将选中的终端文本复制到剪贴板。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "放開滑鼠按鍵時,將選取的終端機文字複製到剪貼簿。" + } + } + } + }, "copy" : { "extractionState" : "stale", "localizations" : { @@ -6387,22 +6475,6 @@ } } }, - "Copies selected terminal text to the clipboard when you release the mouse button." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "松开鼠标按键时,将选中的终端文本复制到剪贴板。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "放開滑鼠按鍵時,將選取的終端機文字複製到剪貼簿。" - } - } - } - }, "Copy" : { "localizations" : { "zh-Hans" : { @@ -6565,6 +6637,22 @@ } } }, + "Copy on Selection" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选中后自动复制" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選取後自動複製" + } + } + } + }, "Copy Path" : { "localizations" : { "zh-Hans" : { @@ -6647,22 +6735,6 @@ } } }, - "Copy on Selection" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选中后自动复制" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選取後自動複製" - } - } - } - }, "Copy to %@" : { "localizations" : { "zh-Hans" : { @@ -7138,6 +7210,7 @@ } }, "day" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -9345,6 +9418,40 @@ } } }, + "Enter a valid project and location to configure Vertex AI." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入有效的项目和区域以配置Vertex AI。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "輸入有效的專案與區域以設定Vertex AI。" + } + } + } + }, + "Enter a valid provider URL, or a Vertex project and location." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请输入有效的提供方URL,或Vertex项目和区域。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請輸入有效的供應商URL,或Vertex專案與區域。" + } + } + } + }, "Enter an absolute device path and a valid baud rate." : { "localizations" : { "zh-Hans" : { @@ -9427,6 +9534,23 @@ } } }, + "Error Details" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "错误详情" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錯誤詳細資訊" + } + } + } + }, "Escape" : { "extractionState" : "manual", "localizations" : { @@ -10888,6 +11012,23 @@ } } }, + "Google Vertex AI" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Vertex AI" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Vertex AI" + } + } + } + }, "Got It" : { "comment" : "A button that dismisses the onboarding flow.", "isCommentAutoGenerated" : true, @@ -12031,6 +12172,23 @@ } } }, + "Import Edited Copy…" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入已编辑副本…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "輸入已編輯副本…" + } + } + } + }, "Import failed." : { "extractionState" : "stale", "localizations" : { @@ -12398,6 +12556,23 @@ } } }, + "install" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "安裝" + } + } + } + }, "Instructions" : { "comment" : "A label for the instructions of a skill.", "isCommentAutoGenerated" : true, @@ -12657,6 +12832,23 @@ } } }, + "Keep Local Copy" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保留本地副本" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "保留本機副本" + } + } + } + }, "Keep saved password" : { "localizations" : { "zh-Hans" : { @@ -13454,6 +13646,23 @@ } } }, + "Location" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "区域" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "區域" + } + } + } + }, "Locked" : { "extractionState" : "manual", "localizations" : { @@ -14083,6 +14292,7 @@ } }, "month" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -14114,6 +14324,22 @@ } } }, + "Mouse" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠" + } + } + } + }, "move" : { "extractionState" : "stale", "localizations" : { @@ -14131,22 +14357,6 @@ } } }, - "Mouse" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "鼠标" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "滑鼠" - } - } - } - }, "Move this conversation to Archive" : { "extractionState" : "stale", "localizations" : { @@ -15022,6 +15232,23 @@ } } }, + "No installed app can open this file." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有已安装的应用可以打开此文件。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有已安裝的App可以打開此檔案。" + } + } + } + }, "No Key" : { "localizations" : { "zh-Hans" : { @@ -15862,6 +16089,7 @@ } }, "One payment. No subscription or automatic renewal." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -16193,6 +16421,23 @@ } } }, + "Open Local Copy" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开本地副本" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "打開本機副本" + } + } + } + }, "Open Login Items Settings" : { "localizations" : { "zh-Hans" : { @@ -16292,6 +16537,23 @@ } } }, + "Open With" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开方式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "打開方式" + } + } + } + }, "Open with SFTP" : { "localizations" : { "zh-Hans" : { @@ -16324,6 +16586,22 @@ } } }, + "Open With…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开方式…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "打開方式…" + } + } + } + }, "Open Workspace" : { "localizations" : { "zh-Hans" : { @@ -16490,6 +16768,23 @@ } } }, + "Other…" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他…" + } + } + } + }, "Others" : { "extractionState" : "manual", "localizations" : { @@ -16556,6 +16851,23 @@ } } }, + "Overwrite Remote File" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖远端文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆寫遠端檔案" + } + } + } + }, "Overwrites `%@`." : { "extractionState" : "stale", "localizations" : { @@ -16939,6 +17251,23 @@ } } }, + "Paste Service Account JSON" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "粘贴服务账号JSON" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "貼上服務帳戶JSON" + } + } + } + }, "Pasted image" : { "localizations" : { "zh-Hans" : { @@ -17237,6 +17566,23 @@ } } }, + "persistent memory" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "持久化记忆" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "持久化記憶" + } + } + } + }, "Photo" : { "extractionState" : "manual", "localizations" : { @@ -17833,6 +18179,23 @@ } } }, + "Project ID" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "项目ID" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "專案ID" + } + } + } + }, "Provider default" : { "localizations" : { "zh-Hans" : { @@ -18201,6 +18564,23 @@ } } }, + "Quit Anyway" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仍然退出" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "仍然結束" + } + } + } + }, "Quit Termind" : { "localizations" : { "zh-Hans" : { @@ -18217,6 +18597,23 @@ } } }, + "Quit with unuploaded changes?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仍有修改未上传,确定退出吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "仍有修改未上傳,確定結束嗎?" + } + } + } + }, "Ran “%@”" : { "extractionState" : "stale", "localizations" : { @@ -18563,6 +18960,23 @@ } } }, + "Reconnect to upload changes" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新连接以上传修改" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新連線以上傳修改" + } + } + } + }, "Reconnected; a new remote shell has started." : { "localizations" : { "zh-Hans" : { @@ -19000,6 +19414,23 @@ } } }, + "Remove Service Account" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除服务账号" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除服務帳戶" + } + } + } + }, "Remove tag %@" : { "localizations" : { "zh-Hans" : { @@ -20174,6 +20605,23 @@ } } }, + "Save this copy separately to keep the original remote file format." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请单独保存此副本,以保留远端文件的原始格式。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請另外儲存此副本,以保留遠端檔案的原始格式。" + } + } + } + }, "Save Workspace Template" : { "localizations" : { "zh-Hans" : { @@ -20190,6 +20638,23 @@ } } }, + "saved conversations" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "历史会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "歷史對話" + } + } + } + }, "scratch files" : { "extractionState" : "stale", "localizations" : { @@ -20959,6 +21424,23 @@ } } }, + "Service Account" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务账号" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "服務帳戶" + } + } + } + }, "Set baud rate" : { "localizations" : { "zh-Hans" : { @@ -21252,6 +21734,23 @@ } } }, + "Show less" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收起" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "收合" + } + } + } + }, "Show Logs" : { "extractionState" : "stale", "localizations" : { @@ -21269,6 +21768,23 @@ } } }, + "Show more" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示更多" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示更多" + } + } + } + }, "Show Session Details" : { "localizations" : { "zh-Hans" : { @@ -21592,6 +22108,40 @@ } } }, + "skill files" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "技能文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "技能檔案" + } + } + } + }, + "skills" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "技能" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "技能" + } + } + } + }, "Skills" : { "comment" : "A label for the \"Skills\" section.", "isCommentAutoGenerated" : true, @@ -22167,6 +22717,7 @@ } }, "Start Free Trial" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -22617,6 +23168,7 @@ } }, "Subscribe Annually" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -23591,6 +24143,23 @@ } } }, + "The edited copy uses a different format" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑后的副本使用了不同格式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯後的副本使用了不同格式" + } + } + } + }, "The extracted text is longer than 200,000 characters." : { "extractionState" : "stale", "localizations" : { @@ -24100,6 +24669,23 @@ } } }, + "The local file changed while preparing the upload. Try again." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "准备上传时本地文件发生了更改。请重试。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "準備上傳時本機檔案發生了變更。請重試。" + } + } + } + }, "The metrics response is incomplete or too large." : { "extractionState" : "stale", "localizations" : { @@ -24182,6 +24768,23 @@ } } }, + "The original connection is no longer available." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "原连接已不可用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "原連線已無法使用。" + } + } + } + }, "The password was saved, but its previous Keychain copy could not be removed: %@" : { "extractionState" : "stale", "localizations" : { @@ -24406,6 +25009,57 @@ } } }, + "The remote file changed or could not be verified. Replace it with your saved local changes?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远端文件已更改或无法验证。要用已保存的本地修改替换它吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端檔案已變更或無法驗證。要用已儲存的本機修改取代它嗎?" + } + } + } + }, + "The remote file changed. Review it before uploading again." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远端文件已更改。请检查后再上传。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端檔案已變更。請檢查後再上傳。" + } + } + } + }, + "The remote file has changed" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远端文件已更改" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端檔案已變更" + } + } + } + }, "The remote file is no longer available." : { "localizations" : { "zh-Hans" : { @@ -25347,6 +26001,23 @@ } } }, + "This file cannot be uploaded back to its original path." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法将此文件上传回原路径。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法將此檔案上傳回原路徑。" + } + } + } + }, "This file format is not supported." : { "extractionState" : "stale", "localizations" : { @@ -26456,6 +27127,40 @@ } } }, + "Unable to inspect edited file" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法检查编辑后的文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法檢查編輯後的檔案" + } + } + } + }, + "Unable to inspect remote edit destination" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法检查远端编辑目标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法檢查遠端編輯目標" + } + } + } + }, "Unable to listen on %@: %@" : { "extractionState" : "stale", "localizations" : { @@ -26489,6 +27194,23 @@ } } }, + "Unable to preserve remote file permissions" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法保留远端文件权限" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法保留遠端檔案權限" + } + } + } + }, "Unable to Read File" : { "extractionState" : "manual", "localizations" : { @@ -27120,6 +27842,23 @@ } } }, + "Uploading changes" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在上传修改" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在上傳修改" + } + } + } + }, "Uptime" : { "extractionState" : "stale", "localizations" : { @@ -27491,6 +28230,40 @@ } } }, + "Vertex AI" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertex AI" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertex AI" + } + } + } + }, + "Vertex AI requires a service account. Add model IDs manually; model discovery is unavailable." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertex AI需要服务账号。不支持模型发现,请手动添加模型ID。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertex AI需要服務帳戶。不支援模型探索,請手動新增模型ID。" + } + } + } + }, "View Termind Pro" : { "comment" : "A button that opens the paywall for Termind Pro.", "isCommentAutoGenerated" : true, @@ -27878,6 +28651,7 @@ } }, "week" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -28377,6 +29151,7 @@ } }, "year" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -28444,6 +29219,23 @@ } } }, + "Your edited files have not all been uploaded. Pending edits will not be restored next time." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑过的文件尚未全部上传。下次启动时不会恢复待上传记录。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯過的檔案尚未全部上傳。下次啟動時不會還原待上傳記錄。" + } + } + } + }, "Your terminal, with judgment." : { "localizations" : { "zh-Hans" : { @@ -28462,4 +29254,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index 66181258..bda3e243 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -609,6 +609,10 @@ struct ComposerView: View { TextEditor(text: draft) .font(.body) .scrollContentBackground(.hidden) + .scrollIndicators( + draftHeight > maximumDraftHeight ? .automatic : .hidden, + axes: .vertical + ) .focused($isFocused) .overlay(alignment: .topLeading) { if draft.wrappedValue.isEmpty, !hasMarkedText { diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 4ef65b02..9db25022 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -1847,11 +1847,17 @@ private final class NativeMarkdownView: NSView { } @MainActor -private final class NativeMarkdownSegment: NSView { +final class NativeMarkdownSegment: NSView { private let markdown = MarkdownTextView() private var content: MarkdownContent? private var measuredSize = NSSize(width: 1, height: 1) private var heightConstraint: NSLayoutConstraint! + private var horizontalScrollViews: [NSScrollView] = [] + private var horizontalTrackingAreas: [ObjectIdentifier: NSTrackingArea] = [:] + private var horizontalScrollerVisibility: [ObjectIdentifier: Bool] = [:] + private var horizontalScrollerAnimationGenerations: [ObjectIdentifier: Int] = [:] + private var pendingHorizontalScrollerFadeOuts: [ObjectIdentifier: Int] = [:] + private weak var hoveredHorizontalScrollView: NSScrollView? override init(frame: CGRect) { super.init(frame: frame) @@ -1912,12 +1918,235 @@ private final class NativeMarkdownSegment: NSView { let measuredHeight = max(1, ceil(markdown.boundingSize(for: measuredWidth).height)) measuredSize = NSSize(width: measuredWidth, height: measuredHeight) heightConstraint.constant = measuredHeight + markdown.layoutSubtreeIfNeeded() + synchronizeHorizontalScrollViews() invalidateIntrinsicContentSize() } + override func layout() { + super.layout() + markdown.layoutSubtreeIfNeeded() + synchronizeHorizontalScrollViews() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { setHoveredHorizontalScrollView(nil, animated: false) } + } + + override func mouseEntered(with event: NSEvent) { + guard let scrollView = horizontalScrollView(for: event.trackingArea) else { + super.mouseEntered(with: event) + return + } + setHoveredHorizontalScrollView(scrollView) + } + + override func mouseExited(with event: NSEvent) { + guard let scrollView = horizontalScrollView(for: event.trackingArea) else { + super.mouseExited(with: event) + return + } + if hoveredHorizontalScrollView === scrollView { + setHoveredHorizontalScrollView(nil) + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = superview.map { convert(point, from: $0) } ?? point + if let scroller = visibleHorizontalScroller(at: localPoint) { + return scroller + } + return super.hitTest(point) + } + override var intrinsicContentSize: NSSize { measuredSize } + + func updateHorizontalScrollerHover(at point: NSPoint?) { + setHoveredHorizontalScrollView(point.flatMap(horizontalScrollView(at:))) + } + + private func setHoveredHorizontalScrollView( + _ hovered: NSScrollView?, + animated: Bool = true + ) { + hoveredHorizontalScrollView = hovered + for scrollView in horizontalScrollViews { + let visible = horizontalOverflow(in: scrollView) && scrollView === hovered + updateHorizontalScrollerVisibility( + visible, + in: scrollView, + animated: animated && window != nil + ) + } + } + + private func updateHorizontalScrollerVisibility( + _ visible: Bool, + in scrollView: NSScrollView, + animated: Bool + ) { + guard let scroller = scrollView.horizontalScroller else { return } + let identifier = ObjectIdentifier(scrollView) + guard horizontalScrollerVisibility[identifier] != visible else { + if visible, scroller.isHidden { + scroller.isHidden = false + scroller.alphaValue = 1 + } else if !visible, pendingHorizontalScrollerFadeOuts[identifier] == nil { + scroller.alphaValue = 0 + scroller.isHidden = true + } + return + } + horizontalScrollerVisibility[identifier] = visible + let generation = horizontalScrollerAnimationGenerations[identifier, default: 0] + 1 + horizontalScrollerAnimationGenerations[identifier] = generation + + if visible { + pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier) + scroller.isHidden = false + } + guard animated else { + pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier) + scroller.alphaValue = visible ? 1 : 0 + scroller.isHidden = !visible + return + } + + if visible { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.allowsImplicitAnimation = true + scroller.animator().alphaValue = 1 + } + } else { + pendingHorizontalScrollerFadeOuts[identifier] = generation + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.allowsImplicitAnimation = true + scroller.animator().alphaValue = 0 + } completionHandler: { [weak self, weak scroller] in + Task { @MainActor in + guard let self, let scroller, + self.horizontalScrollerVisibility[identifier] == false, + self.pendingHorizontalScrollerFadeOuts[identifier] == generation + else { return } + self.pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier) + scroller.isHidden = true + } + } + } + } + + private func synchronizeHorizontalScrollViews() { + let previous = horizontalScrollViews + let candidates = descendantScrollViews(in: markdown).filter { + !$0.hasVerticalScroller && $0.documentView != nil + } + horizontalScrollViews = candidates + + for scrollView in previous where !candidates.contains(where: { $0 === scrollView }) { + removeHorizontalTrackingArea(from: scrollView) + updateHorizontalScrollerVisibility(false, in: scrollView, animated: false) + horizontalScrollerVisibility.removeValue(forKey: ObjectIdentifier(scrollView)) + horizontalScrollerAnimationGenerations.removeValue(forKey: ObjectIdentifier(scrollView)) + pendingHorizontalScrollerFadeOuts.removeValue(forKey: ObjectIdentifier(scrollView)) + } + + for scrollView in candidates { + scrollView.scrollerStyle = .overlay + scrollView.hasHorizontalScroller = true + scrollView.autohidesScrollers = false + scrollView.horizontalScroller?.controlSize = .mini + scrollView.tile() + + let overflows = horizontalOverflow(in: scrollView) + let scroller = scrollView.horizontalScroller + scroller?.scrollerStyle = .legacy + scroller?.isEnabled = overflows + if overflows { + installHorizontalTrackingAreaIfNeeded(on: scrollView) + if horizontalScrollerVisibility[ObjectIdentifier(scrollView)] == nil { + updateHorizontalScrollerVisibility(false, in: scrollView, animated: false) + } + } else { + removeHorizontalTrackingArea(from: scrollView) + updateHorizontalScrollerVisibility(false, in: scrollView, animated: false) + } + } + + if let hoveredHorizontalScrollView, + !candidates.contains(where: { $0 === hoveredHorizontalScrollView }) + || !horizontalOverflow(in: hoveredHorizontalScrollView) + { + self.hoveredHorizontalScrollView = nil + } + setHoveredHorizontalScrollView(hoveredHorizontalScrollView, animated: false) + } + + private func installHorizontalTrackingAreaIfNeeded(on scrollView: NSScrollView) { + let identifier = ObjectIdentifier(scrollView) + guard horizontalTrackingAreas[identifier] == nil else { return } + let area = NSTrackingArea( + rect: .zero, + options: [.activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + scrollView.addTrackingArea(area) + horizontalTrackingAreas[identifier] = area + } + + private func removeHorizontalTrackingArea(from scrollView: NSScrollView) { + guard let area = horizontalTrackingAreas.removeValue(forKey: ObjectIdentifier(scrollView)) else { + return + } + scrollView.removeTrackingArea(area) + } + + private func horizontalScrollView(for trackingArea: NSTrackingArea?) -> NSScrollView? { + guard let trackingArea else { return nil } + return horizontalScrollViews.first { scrollView in + horizontalTrackingAreas[ObjectIdentifier(scrollView)] === trackingArea + } + } + + private func horizontalScrollView(at point: NSPoint) -> NSScrollView? { + horizontalScrollViews.last { scrollView in + horizontalOverflow(in: scrollView) + && convert(scrollView.bounds, from: scrollView).contains(point) + } + } + + private func visibleHorizontalScroller(at point: NSPoint) -> NSScroller? { + for scrollView in horizontalScrollViews.reversed() { + guard horizontalOverflow(in: scrollView), + let scroller = scrollView.horizontalScroller, + !scroller.isHidden, + scroller.alphaValue > 0.01 + else { continue } + let scrollerPoint = scroller.convert(point, from: self) + if scroller.bounds.contains(scrollerPoint) { return scroller } + } + return nil + } + + private func horizontalOverflow(in scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + return documentView.frame.width > scrollView.contentView.bounds.width + 1 + } + + private func descendantScrollViews(in root: NSView) -> [NSScrollView] { + var result: [NSScrollView] = [] + var pending = root.subviews + while let view = pending.popLast() { + if let scrollView = view as? NSScrollView { result.append(scrollView) } + pending.append(contentsOf: view.subviews) + } + return result + } } @MainActor diff --git a/TFMTests/TranscriptListViewTests.swift b/TFMTests/TranscriptListViewTests.swift index a7f98cbe..7dd1f3a8 100644 --- a/TFMTests/TranscriptListViewTests.swift +++ b/TFMTests/TranscriptListViewTests.swift @@ -1118,6 +1118,145 @@ struct TranscriptListViewBridgeTests { #expect(actions.alphaValue == 0) } + @Test + func overflowingMarkdownBlocksExposeHoverableHorizontalScrollers() throws { + let coordinator = TranscriptListView.Coordinator() + let listView = coordinator.makeListView() + let id = UUID() + listView.frame = CGRect(x: 0, y: 0, width: 320, height: 800) + let longValue = String(repeating: "wide-content-", count: 24) + let markdown = """ + | Name | Value | + | --- | --- | + | table | \(longValue) | + + ```swift + let value = "\(longValue)" + ``` + """ + + update(coordinator, listView, entries: [assistant(id: id, text: markdown)]) + listView.layoutSubtreeIfNeeded() + + let row = try #require(listView.rowView(for: id)) + let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self)) + segment.layoutSubtreeIfNeeded() + let scrollViews = horizontalScrollViews(in: segment) + let allHaveHorizontalScrollers = scrollViews.allSatisfy { $0.hasHorizontalScroller } + let allScrollersAreHidden = scrollViews.allSatisfy { + $0.horizontalScroller?.alphaValue == 0 + && $0.horizontalScroller?.isHidden == true + } + #expect(scrollViews.count == 2) + #expect(allHaveHorizontalScrollers) + #expect(allScrollersAreHidden) + #expect(segment.trackingAreas.allSatisfy { !$0.options.contains(.mouseMoved) }) + + let trackingAreas = try scrollViews.map { scrollView in + try #require( + scrollView.trackingAreas.first { area in + area.owner === segment + && area.options.contains(.inVisibleRect) + && area.options.contains(.mouseEnteredAndExited) + && !area.options.contains(.mouseMoved) + } + ) + } + segment.needsLayout = true + segment.layoutSubtreeIfNeeded() + for (scrollView, trackingArea) in zip(scrollViews, trackingAreas) { + let ownedTrackingAreas = scrollView.trackingAreas.filter { $0.owner === segment } + #expect(ownedTrackingAreas.count == 1) + #expect(ownedTrackingAreas.first === trackingArea) + } + + for scrollView in scrollViews { + let scroller = try #require(scrollView.horizontalScroller) + let contentHit = [ + CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.minY + 2), + CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.maxY - 2), + CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY), + ].lazy.compactMap { point -> (NSPoint, NSView)? in + guard !scroller.frame.contains(point) else { return nil } + let localPoint = segment.convert(point, from: scrollView) + guard let superview = segment.superview else { return nil } + let hitPoint = superview.convert(localPoint, from: segment) + guard let hit = segment.hitTest(hitPoint), !(hit is NSScroller) else { return nil } + return (hitPoint, hit) + }.first + + let hoverPoint = segment.convert( + CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY), + from: scrollView + ) + segment.updateHorizontalScrollerHover(at: hoverPoint) + + #expect(scroller.alphaValue == 1) + #expect(!scroller.isHidden) + #expect(scroller.scrollerStyle == .legacy) + #expect(scroller.controlSize == .mini) + #expect(scroller.frame.width > 0) + #expect(scroller.frame.height > 0) + #expect( + scroller.frame.height + <= NSScroller.scrollerWidth(for: .mini, scrollerStyle: .legacy) + 1 + ) + if let (hitPoint, hitBeforeHover) = contentHit { + #expect(segment.hitTest(hitPoint) === hitBeforeHover) + } + + let localScrollerPoint = segment.convert( + CGPoint(x: scroller.bounds.midX, y: scroller.bounds.midY), + from: scroller + ) + let scrollerHitPoint = try #require(segment.superview).convert(localScrollerPoint, from: segment) + #expect(segment.hitTest(scrollerHitPoint) === scroller) + + let documentView = try #require(scrollView.documentView) + let maximumX = documentView.frame.width - scrollView.contentView.bounds.width + scrollView.contentView.scroll(to: CGPoint(x: maximumX, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + #expect(scrollView.contentView.bounds.minX > 0) + } + + segment.updateHorizontalScrollerHover(at: nil) + #expect(scrollViews.allSatisfy { $0.horizontalScroller?.alphaValue == 0 }) + #expect(scrollViews.allSatisfy { $0.horizontalScroller?.isHidden == true }) + } + + @Test + func markdownScrollersStayHiddenWhenTheirContentFits() throws { + let coordinator = TranscriptListView.Coordinator() + let listView = coordinator.makeListView() + let id = UUID() + listView.frame = CGRect(x: 0, y: 0, width: 900, height: 800) + let markdown = """ + | Name | Value | + | --- | --- | + | table | short | + + ```swift + let value = 1 + ``` + """ + + update(coordinator, listView, entries: [assistant(id: id, text: markdown)]) + listView.layoutSubtreeIfNeeded() + + let row = try #require(listView.rowView(for: id)) + let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self)) + segment.layoutSubtreeIfNeeded() + let scrollViews = descendants(in: segment, ofType: NSScrollView.self) + #expect(scrollViews.count == 2) + + segment.updateHorizontalScrollerHover( + at: CGPoint(x: segment.bounds.midX, y: segment.bounds.midY) + ) + #expect(scrollViews.allSatisfy { $0.horizontalScroller?.alphaValue == 0 }) + #expect(scrollViews.allSatisfy { $0.horizontalScroller?.isHidden == true }) + #expect(scrollViews.allSatisfy { $0.horizontalScroller?.isEnabled == false }) + } + @Test func disclosureRefreshRebuildsTheVisiblePlanAndItsHeight() { let coordinator = TranscriptListView.Coordinator() @@ -1349,6 +1488,28 @@ struct TranscriptListViewBridgeTests { return nil } + private func descendant(in root: NSView, ofType _: View.Type) -> View? { + descendants(in: root, ofType: View.self).first + } + + private func descendants(in root: NSView, ofType _: View.Type) -> [View] { + var result: [View] = [] + var queue = root.subviews + while let view = queue.first { + queue.removeFirst() + if let match = view as? View { result.append(match) } + queue.append(contentsOf: view.subviews) + } + return result + } + + private func horizontalScrollViews(in root: NSView) -> [NSScrollView] { + descendants(in: root, ofType: NSScrollView.self).filter { scrollView in + guard let documentView = scrollView.documentView else { return false } + return documentView.frame.width > scrollView.contentView.bounds.width + 1 + } + } + private func expectProminentForeground(_ button: NSButton, component: CGFloat) throws { let titleColor = try #require( button.attributedTitle.attribute( diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 32a4fb12..bf18cd80 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -67,6 +67,7 @@ C2D4B372BA5A040DDBCEFC85 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; C4AD00000000000000000001 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100015 /* ZIPFoundation */; }; C4AD00000000000000000002 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100017 /* MarkdownView */; }; + CE6200000000000000000001 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; CF863393BB8C2B38A9EEE18A /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; D7D1BBB65020F61F7EFDDFD7 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100012 /* GhosttyTerminal */; }; E635C54CA9F1A63D909CFCE6 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; @@ -88,7 +89,6 @@ TRMD0000000000000010000D /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; TRMD0000000000000010000E /* AgentKitScrubber in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001C /* AgentKitScrubber */; }; TRMD0000000000000010000F /* AgentKitScrubber in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001C /* AgentKitScrubber */; }; - CE6200000000000000000001 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -183,6 +183,13 @@ remoteGlobalIDString = A5F3ED08556B8962920EF788; remoteInfo = Framework; }; + CE6200000000000000000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BDA6B93652DE90AEBDA7090E; + remoteInfo = TermindRemoteInfrastructure; + }; DB8AB3CC3053A07800C7EFAE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = TRMD00000000000000000090 /* Project object */; @@ -386,13 +393,6 @@ remoteGlobalIDString = TRMD00000000000000030050; remoteInfo = TermindMCPBridge; }; - CE6200000000000000000002 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = TRMD00000000000000000090 /* Project object */; - proxyType = 1; - remoteGlobalIDString = BDA6B93652DE90AEBDA7090E; - remoteInfo = TermindRemoteInfrastructure; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -1603,6 +1603,11 @@ target = A5F3ED08556B8962920EF788 /* TermindApplication */; targetProxy = DB8AB3DD3053A07900C7EFAE /* PBXContainerItemProxy */; }; + CE6200000000000000000003 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */; + targetProxy = CE6200000000000000000002 /* PBXContainerItemProxy */; + }; D6444C208365A08727ACC139 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B243FDB536F5111B356353DA /* TermindDomain */; @@ -1638,11 +1643,6 @@ target = TRMD00000000000000030050 /* TermindMCPBridge */; targetProxy = TRMD00000000000000030090 /* PBXContainerItemProxy */; }; - CE6200000000000000000003 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */; - targetProxy = CE6200000000000000000002 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ diff --git a/Termind.xcodeproj/xcshareddata/xcschemes/TermindMCPBridge.xcscheme b/Termind.xcodeproj/xcshareddata/xcschemes/TermindMCPBridge.xcscheme new file mode 100644 index 00000000..c45356c7 --- /dev/null +++ b/Termind.xcodeproj/xcshareddata/xcschemes/TermindMCPBridge.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +