From dc09a642a62d496bcbeb9f76d63e9b3f4cecb55c Mon Sep 17 00:00:00 2001 From: DamnCrab <42539593+damncrab@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:29:09 +0800 Subject: [PATCH] feat: sync upstream UI refactor and preserve i18n coverage Bring the fork back on top of upstream/main while keeping only the translation-related work that is suitable for an upstream PR. What changed: - adopt the upstream sidebar/composer/home/git UI refactors instead of keeping the older fork-only structure - re-apply i18n integration on top of the new upstream component split - add the missing locale keys required by the upstream UI changes - update locale-sensitive tests so they validate translated behavior without depending on one specific relative-time wording - drop fork-only GitHub Actions workflow changes from the final diff Why: - keep the PR reviewable for upstream by reducing history noise - make the fork compatible with the latest upstream UI architecture - ensure translated strings remain complete after the upstream refactor --- package-lock.json | 133 ++- package.json | 3 + src-tauri/src/shared/git_ui_core/commands.rs | 513 ++++++++- src-tauri/src/shared/git_ui_core/diff.rs | 102 +- src-tauri/src/shared/git_ui_core/github.rs | 18 +- src-tauri/src/shared/git_ui_core/log.rs | 172 ++- src-tauri/src/shared/git_ui_core/tests.rs | 221 ++++ src-tauri/src/types.rs | 8 + .../app/components/AppChromeI18n.test.tsx | 127 +++ .../app/components/LaunchScriptButton.tsx | 40 +- .../components/LaunchScriptEntryButton.tsx | 24 +- src/features/app/components/MainHeader.tsx | 16 +- src/features/app/components/Sidebar.test.tsx | 1 + src/features/app/components/Sidebar.tsx | 42 +- .../app/components/SidebarBottomRail.tsx | 32 +- src/features/app/components/SidebarHeader.tsx | 40 +- .../app/components/SidebarSearchBar.tsx | 8 +- .../components/SidebarThreadsOnlySection.tsx | 8 +- .../app/components/SidebarWorkspaceGroups.tsx | 20 +- src/features/app/components/TabBar.tsx | 23 +- src/features/app/components/ThreadList.tsx | 10 +- src/features/app/components/WorkspaceCard.tsx | 10 +- .../app/components/WorktreeSection.tsx | 6 +- .../app/hooks/useMainAppDisplayNodes.tsx | 8 +- .../app/hooks/useSidebarMenus.test.tsx | 59 +- src/features/app/hooks/useSidebarMenus.ts | 43 +- .../app/hooks/useTraySessionUsage.test.tsx | 14 +- .../orchestration/useLayoutOrchestration.ts | 4 +- .../ComposerInput.dictation.test.tsx | 47 +- .../composer/components/ComposerInput.tsx | 30 +- .../composer/components/ComposerMetaBar.tsx | 14 +- .../components/ComposerMobileActionsMenu.tsx | 12 +- .../hooks/useComposerDictationControls.ts | 19 +- .../files/components/FilePreviewPopover.tsx | 27 +- .../files/components/FileTreePanel.tsx | 66 +- src/features/git/components/GitDiffPanel.tsx | 52 +- .../git/components/GitDiffPanelListModes.tsx | 48 +- .../git/components/GitDiffPanelOverview.tsx | 29 +- .../git/components/GitDiffPanelShared.tsx | 55 +- .../git/components/GitDiffViewer.test.tsx | 46 + src/features/git/components/GitDiffViewer.tsx | 5 +- .../git/components/GitDiffViewer.types.ts | 1 + .../git/components/GitDiffViewer.utils.ts | 8 + .../git/components/GitDiffViewerDiffCard.tsx | 26 +- src/features/git/hooks/useGitDiffs.ts | 1 + src/features/home/components/Home.test.tsx | 12 + src/features/home/components/Home.tsx | 8 +- src/features/home/components/HomeActions.tsx | 8 +- .../components/HomeLatestAgentsSection.tsx | 17 +- .../home/components/HomeUsageSection.tsx | 124 +- src/features/home/homeFormatters.ts | 72 +- src/features/home/homeUsageViewModel.ts | 235 ++-- .../layout/components/DesktopLayout.tsx | 30 +- src/features/layout/components/PanelTabs.tsx | 38 +- .../components/SidebarToggleControls.tsx | 29 +- .../layout/components/TabletLayout.tsx | 4 +- .../components/WindowCaptionControls.tsx | 16 +- .../notifications/components/ErrorToasts.tsx | 6 +- .../prompts/components/PromptPanel.tsx | 92 +- .../settings/components/SettingsNav.tsx | 28 +- .../settings/components/SettingsView.test.tsx | 40 +- .../settings/components/SettingsView.tsx | 14 +- .../sections/SettingsAboutSection.tsx | 47 +- .../sections/SettingsAgentsSection.tsx | 169 +-- .../sections/SettingsCodexSection.tsx | 170 +-- .../sections/SettingsComposerSection.tsx | 84 +- .../sections/SettingsDictationSection.tsx | 50 +- .../sections/SettingsDisplaySection.tsx | 138 ++- .../sections/SettingsEnvironmentsSection.tsx | 78 +- .../sections/SettingsFeaturesSection.tsx | 66 +- .../sections/SettingsGitSection.tsx | 30 +- .../sections/SettingsOpenAppsSection.tsx | 78 +- .../sections/SettingsProjectsSection.tsx | 46 +- .../sections/SettingsServerSection.tsx | 191 ++-- .../sections/SettingsShortcutsSection.tsx | 161 ++- .../components/settingsViewConstants.ts | 16 + src/features/settings/hooks/useAppSettings.ts | 14 +- .../settings/hooks/useGlobalAgentsMd.ts | 6 +- .../hooks/useGlobalCodexConfigToml.ts | 6 +- .../shared/components/FileEditorCard.tsx | 13 +- .../terminal/components/TerminalDock.tsx | 12 +- .../workspaces/components/WorkspaceHome.tsx | 24 +- .../components/WorkspaceHomeHistory.test.tsx | 80 ++ .../components/WorkspaceHomeHistory.tsx | 48 +- .../workspaces/hooks/useWorkspaceAgentMd.ts | 6 +- src/i18n.test.ts | 82 ++ src/i18n.ts | 68 ++ src/locales/ar/common.json | 1009 +++++++++++++++++ src/locales/de/common.json | 1009 +++++++++++++++++ src/locales/en/common.json | 1009 +++++++++++++++++ src/locales/es/common.json | 1009 +++++++++++++++++ src/locales/fr/common.json | 1009 +++++++++++++++++ src/locales/hi/common.json | 1009 +++++++++++++++++ src/locales/index.ts | 25 + src/locales/ja/common.json | 1009 +++++++++++++++++ src/locales/ko/common.json | 1009 +++++++++++++++++ src/locales/pt/common.json | 1009 +++++++++++++++++ src/locales/ru/common.json | 1009 +++++++++++++++++ src/locales/zh/common.json | 1009 +++++++++++++++++ src/main.tsx | 1 + src/test/vitest.setup.ts | 24 + src/types.ts | 3 + src/utils/threadStatus.test.ts | 8 +- src/utils/threadStatus.ts | 8 +- 104 files changed, 14473 insertions(+), 1342 deletions(-) create mode 100644 src/features/app/components/AppChromeI18n.test.tsx create mode 100644 src/features/workspaces/components/WorkspaceHomeHistory.test.tsx create mode 100644 src/i18n.test.ts create mode 100644 src/i18n.ts create mode 100644 src/locales/ar/common.json create mode 100644 src/locales/de/common.json create mode 100644 src/locales/en/common.json create mode 100644 src/locales/es/common.json create mode 100644 src/locales/fr/common.json create mode 100644 src/locales/hi/common.json create mode 100644 src/locales/index.ts create mode 100644 src/locales/ja/common.json create mode 100644 src/locales/ko/common.json create mode 100644 src/locales/pt/common.json create mode 100644 src/locales/ru/common.json create mode 100644 src/locales/zh/common.json diff --git a/package-lock.json b/package-lock.json index 9ffa1ed73..6380f17be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,13 @@ "@tauri-apps/plugin-updater": "^2.10.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.562.0", "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tauri-plugin-liquid-glass-api": "^0.1.6", @@ -133,6 +136,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -340,7 +344,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -482,6 +485,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -525,6 +529,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1784,6 +1789,7 @@ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", "license": "Apache-2.0 OR MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/tauri" @@ -2105,8 +2111,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2231,6 +2236,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2241,6 +2247,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2291,6 +2298,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2612,7 +2620,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/acorn": { "version": "8.15.0", @@ -2620,6 +2629,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2703,7 +2713,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2972,6 +2981,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3496,8 +3506,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3789,6 +3798,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4692,6 +4702,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4740,6 +4759,47 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "25.8.18", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", + "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5353,6 +5413,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -5558,7 +5619,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6856,6 +6916,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6918,7 +6979,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6934,7 +6994,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6947,8 +7006,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prismjs": { "version": "1.30.0", @@ -7017,6 +7075,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7026,6 +7085,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7033,6 +7093,33 @@ "react": "^19.2.3" } }, + "node_modules/react-i18next": { + "version": "16.5.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz", + "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8159,8 +8246,9 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8316,6 +8404,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -8350,6 +8447,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8515,6 +8613,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vscode-material-icons": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/vscode-material-icons/-/vscode-material-icons-0.1.1.tgz", diff --git a/package.json b/package.json index 1855e62fb..c83c9b64d 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,13 @@ "@tauri-apps/plugin-updater": "^2.10.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.562.0", "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tauri-plugin-liquid-glass-api": "^0.1.6", diff --git a/src-tauri/src/shared/git_ui_core/commands.rs b/src-tauri/src/shared/git_ui_core/commands.rs index 90400d74b..d89327bde 100644 --- a/src-tauri/src/shared/git_ui_core/commands.rs +++ b/src-tauri/src/shared/git_ui_core/commands.rs @@ -9,12 +9,36 @@ use tokio::sync::Mutex; use crate::git_utils::{ checkout_branch, list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, }; -use crate::shared::process_core::tokio_command; +use crate::shared::process_core::{std_command, tokio_command}; use crate::types::{BranchInfo, WorkspaceEntry}; -use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; +use crate::utils::{ + git_env_path, normalize_git_path, normalize_windows_namespace_path, resolve_git_binary, +}; use super::context::workspace_entry_for_id; +fn normalized_repo_root_for_git(repo_root: &Path) -> String { + let sanitized = normalize_windows_namespace_path(repo_root.to_string_lossy().as_ref()); + normalize_git_path(&sanitized) +} + +pub(super) fn normalized_repo_root_for_safe_directory(repo_root: &Path) -> String { + normalized_repo_root_for_git(repo_root) +} + +pub(super) fn prefer_safe_git_cli_repo_access() -> bool { + if cfg!(windows) { + return true; + } + + matches!( + std::env::var("CODEX_MONITOR_FORCE_SAFE_GIT_CLI") + .ok() + .as_deref(), + Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES") + ) +} + async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> { let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; let output = tokio_command(git_bin) @@ -42,6 +66,121 @@ async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> Err(detail.to_string()) } +fn safe_directory_arg(repo_root: &Path) -> String { + format!("safe.directory={}", normalized_repo_root_for_git(repo_root)) +} + +async fn run_git_command_with_safe_directory( + repo_root: &Path, + args: &[&str], +) -> Result<(), String> { + let safe_directory = safe_directory_arg(repo_root); + let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let output = tokio_command(git_bin) + .arg("-c") + .arg(&safe_directory) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("Git command failed.".to_string()); + } + Err(detail.to_string()) +} + +async fn run_git_command_stdout_with_safe_directory( + repo_root: &Path, + args: &[&str], +) -> Result { + let safe_directory = safe_directory_arg(repo_root); + let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let output = tokio_command(git_bin) + .arg("-c") + .arg(&safe_directory) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + return Ok(stdout); + } + + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("Git command failed.".to_string()); + } + Err(detail.to_string()) +} + +fn run_git_command_stdout_with_safe_directory_sync( + repo_root: &Path, + args: &[&str], +) -> Result { + let safe_directory = safe_directory_arg(repo_root); + let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let output = std_command(git_bin) + .arg("-c") + .arg(&safe_directory) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .map_err(|e| format!("Failed to run git: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + return Ok(stdout); + } + + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("Git command failed.".to_string()); + } + Err(detail.to_string()) +} + +fn git_stdout_trim_with_safe_directory_sync( + repo_root: &Path, + args: &[&str], +) -> Result { + Ok( + run_git_command_stdout_with_safe_directory_sync(repo_root, args)? + .trim() + .to_string(), + ) +} + async fn run_gh_command(repo_root: &Path, args: &[&str]) -> Result<(String, String), String> { let output = tokio_command("gh") .args(args) @@ -72,6 +211,93 @@ async fn gh_stdout_trim(repo_root: &Path, args: &[&str]) -> Result bool { + let lower = detail.to_ascii_lowercase(); + lower.contains("not owned by current user") + || lower.contains("dubious ownership") + || lower.contains("safe.directory") + || lower.contains("code=owner") +} + +pub(super) fn is_repository_missing_error(detail: &str) -> bool { + let lower = detail.to_ascii_lowercase(); + lower.contains("could not find repository") + || lower.contains("not a git repository") + || lower.contains("repository not found") + || lower.contains("code=notfound") + || lower.contains("cannot change to") + || lower.contains("no such file or directory") +} + +fn should_retry_repository_command(detail: &str) -> bool { + is_repository_owner_error(detail) || is_repository_missing_error(detail) +} + +fn format_repository_owner_error(repo_root: &Path, detail: &str) -> String { + format!( + "Git could not access repository {} because it is owned by a different user. Add it as a safe directory with `git config --global --add safe.directory \"{}\"`, or update the folder owner. Original error: {}", + repo_root.display(), + normalized_repo_root_for_git(repo_root), + detail + ) +} + +fn format_repository_missing_error(repo_root: &Path, detail: &str) -> String { + format!( + "Git repository not found at {}. Verify the workspace path still exists and points to a Git repository. Original error: {}", + repo_root.display(), + detail + ) +} + +fn normalize_repository_command_error(repo_root: &Path, detail: &str) -> String { + if is_repository_owner_error(detail) { + return format_repository_owner_error(repo_root, detail); + } + if is_repository_missing_error(detail) { + return format_repository_missing_error(repo_root, detail); + } + detail.to_string() +} + +pub(super) fn normalize_repository_access_error(repo_root: &Path, detail: &str) -> String { + normalize_repository_command_error(repo_root, detail) +} + +pub(super) fn parse_git_branch_listing(stdout: &str) -> Vec { + let mut branches = stdout + .lines() + .filter_map(|line| { + let (name, last_commit) = line.split_once('\t')?; + let trimmed = name.trim(); + if trimmed.is_empty() { + return None; + } + Some(BranchInfo { + name: trimmed.to_string(), + last_commit: last_commit.trim().parse::().unwrap_or(0), + }) + }) + .collect::>(); + branches.sort_by(|a, b| b.last_commit.cmp(&a.last_commit)); + branches +} + +async fn list_git_branches_with_safe_directory( + repo_root: &Path, +) -> Result, String> { + let stdout = run_git_command_stdout_with_safe_directory( + repo_root, + &[ + "for-each-ref", + "refs/heads", + "--format=%(refname:short)\t%(committerdate:unix)", + ], + ) + .await?; + Ok(parse_git_branch_listing(&stdout)) +} + async fn gh_git_protocol(repo_root: &Path) -> String { gh_stdout_trim(repo_root, &["config", "get", "git_protocol"]) .await @@ -183,10 +409,121 @@ pub(super) fn github_repo_names_match(existing: &str, requested: &str) -> bool { normalize_repo_full_name(existing).eq_ignore_ascii_case(&normalize_repo_full_name(requested)) } -fn git_remote_url(repo_root: &Path, remote_name: &str) -> Option { - let repo = Repository::open(repo_root).ok()?; - let remote = repo.find_remote(remote_name).ok()?; - remote.url().map(|url| url.to_string()) +pub(super) fn git_remote_names(repo_root: &Path) -> Result, String> { + let list_with_cli = || { + git_stdout_trim_with_safe_directory_sync(repo_root, &["remote"]).map(|stdout| { + stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect::>() + }) + }; + + if prefer_safe_git_cli_repo_access() { + return list_with_cli(); + } + + match Repository::open(repo_root) { + Ok(repo) => Ok(repo + .remotes() + .map_err(|e| e.to_string())? + .iter() + .flatten() + .map(ToString::to_string) + .collect()), + Err(error) => { + let detail = error.to_string(); + if !should_retry_repository_command(&detail) { + return Err(normalize_repository_command_error(repo_root, &detail)); + } + list_with_cli().map_err(|fallback_error| { + normalize_repository_command_error(repo_root, &fallback_error) + }) + } + } +} + +pub(super) fn preferred_git_remote_name(repo_root: &Path) -> Result, String> { + let remotes = git_remote_names(repo_root)?; + if remotes.iter().any(|remote| remote == "origin") { + return Ok(Some("origin".to_string())); + } + Ok(remotes.into_iter().next()) +} + +pub(super) fn git_remote_url(repo_root: &Path, remote_name: &str) -> Option { + let url_with_cli = || { + git_stdout_trim_with_safe_directory_sync(repo_root, &["remote", "get-url", remote_name]) + .ok() + .filter(|url| !url.is_empty()) + }; + + if prefer_safe_git_cli_repo_access() { + return url_with_cli(); + } + + match Repository::open(repo_root) { + Ok(repo) => repo + .find_remote(remote_name) + .ok() + .and_then(|remote| remote.url().map(|url| url.to_string())), + Err(error) => { + let detail = error.to_string(); + if !should_retry_repository_command(&detail) { + return None; + } + url_with_cli() + } + } +} + +pub(super) fn current_branch_name(repo_root: &Path) -> Result, String> { + let branch_with_cli = || { + git_stdout_trim_with_safe_directory_sync( + repo_root, + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .ok() + .filter(|name| !name.is_empty()) + }; + + if prefer_safe_git_cli_repo_access() { + return Ok(branch_with_cli()); + } + + match Repository::open(repo_root) { + Ok(repo) => Ok(repo + .head() + .ok() + .and_then(|head| head.shorthand().map(|name| name.to_string()))), + Err(error) => { + let detail = error.to_string(); + if !should_retry_repository_command(&detail) { + return Err(normalize_repository_command_error(repo_root, &detail)); + } + Ok(branch_with_cli()) + } + } +} + +pub(super) fn is_git_repository(repo_root: &Path) -> bool { + if let Ok(git_bin) = resolve_git_binary() { + let cli_result = std_command(git_bin) + .arg("-c") + .arg(safe_directory_arg(repo_root)) + .args(["rev-parse", "--git-dir"]) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output(); + + if let Ok(output) = cli_result { + return output.status.success(); + } + } + + Repository::open(repo_root).is_ok() } fn gh_repo_create_args<'a>( @@ -312,7 +649,38 @@ fn parse_upstream_ref(name: &str) -> Option<(String, String)> { } fn upstream_remote_and_branch(repo_root: &Path) -> Result, String> { - let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + let upstream_with_cli = || -> Result, String> { + let upstream = match git_stdout_trim_with_safe_directory_sync( + repo_root, + &[ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ], + ) { + Ok(value) => value, + Err(_) => return Ok(None), + }; + Ok(parse_upstream_ref(&upstream)) + }; + + if prefer_safe_git_cli_repo_access() { + return upstream_with_cli(); + } + + let repo = match Repository::open(repo_root) { + Ok(repo) => repo, + Err(error) => { + let detail = error.to_string(); + if !should_retry_repository_command(&detail) { + return Err(normalize_repository_command_error(repo_root, &detail)); + } + return upstream_with_cli().map_err(|fallback_error| { + normalize_repository_command_error(repo_root, &fallback_error) + }); + } + }; let head = match repo.head() { Ok(head) => head, Err(_) => return Ok(None), @@ -538,7 +906,7 @@ pub(super) async fn init_git_repo_inner( let repo_root = resolve_git_root(&entry)?; let branch = validate_branch_name(&branch)?; - if Repository::open(&repo_root).is_ok() { + if is_git_repository(&repo_root) { return Ok(json!({ "status": "already_initialized" })); } @@ -605,12 +973,10 @@ pub(super) async fn create_github_repo_inner( other => return Err(format!("Invalid repo visibility: {other}")), }; - let local_repo = Repository::open(&repo_root) - .map_err(|_| "Git is not initialized in this folder yet.".to_string())?; - let origin_url_before = local_repo - .find_remote("origin") - .ok() - .and_then(|remote| remote.url().map(|url| url.to_string())); + if !is_git_repository(&repo_root) { + return Err("Git is not initialized in this folder yet.".to_string()); + } + let origin_url_before = git_remote_url(&repo_root, "origin"); let full_name = if repo.contains('/') { repo @@ -674,14 +1040,7 @@ pub(super) async fn create_github_repo_inner( let default_branch = if let Some(branch) = branch { Some(validate_branch_name(&branch)?) } else { - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let head = repo.head().ok(); - let name = head - .as_ref() - .filter(|head| head.is_branch()) - .and_then(|head| head.shorthand()) - .map(str::to_string); - name.and_then(|name| validate_branch_name(&name).ok()) + current_branch_name(&repo_root)?.and_then(|name| validate_branch_name(&name).ok()) }; let default_branch_result = if let Some(branch) = default_branch.as_deref() { @@ -728,25 +1087,47 @@ pub(super) async fn list_git_branches_inner( ) -> Result { let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let mut branches = Vec::new(); - let refs = repo - .branches(Some(BranchType::Local)) - .map_err(|e| e.to_string())?; - for branch_result in refs { - let (branch, _) = branch_result.map_err(|e| e.to_string())?; - let name = branch.name().ok().flatten().unwrap_or("").to_string(); - if name.is_empty() { - continue; - } - let last_commit = branch - .get() - .target() - .and_then(|oid| repo.find_commit(oid).ok()) - .map(|commit| commit.time().seconds()) - .unwrap_or(0); - branches.push(BranchInfo { name, last_commit }); + if prefer_safe_git_cli_repo_access() { + let mut branches = list_git_branches_with_safe_directory(&repo_root) + .await + .map_err(|error| normalize_repository_command_error(&repo_root, &error))?; + branches.sort_by(|a, b| b.last_commit.cmp(&a.last_commit)); + return Ok(json!({ "branches": branches })); } + let mut branches = match Repository::open(&repo_root) { + Ok(repo) => { + let mut branches = Vec::new(); + let refs = repo + .branches(Some(BranchType::Local)) + .map_err(|e| e.to_string())?; + for branch_result in refs { + let (branch, _) = branch_result.map_err(|e| e.to_string())?; + let name = branch.name().ok().flatten().unwrap_or("").to_string(); + if name.is_empty() { + continue; + } + let last_commit = branch + .get() + .target() + .and_then(|oid| repo.find_commit(oid).ok()) + .map(|commit| commit.time().seconds()) + .unwrap_or(0); + branches.push(BranchInfo { name, last_commit }); + } + branches + } + Err(error) => { + let detail = error.to_string(); + if !should_retry_repository_command(&detail) { + return Err(detail); + } + list_git_branches_with_safe_directory(&repo_root) + .await + .map_err(|fallback_error| { + normalize_repository_command_error(&repo_root, &fallback_error) + })? + } + }; branches.sort_by(|a, b| b.last_commit.cmp(&a.last_commit)); Ok(json!({ "branches": branches })) } @@ -758,8 +1139,25 @@ pub(super) async fn checkout_git_branch_inner( ) -> Result<(), String> { let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - checkout_branch(&repo, &name).map_err(|e| e.to_string()) + if prefer_safe_git_cli_repo_access() { + return run_git_command_with_safe_directory(&repo_root, &["checkout", &name]) + .await + .map_err(|error| normalize_repository_command_error(&repo_root, &error)); + } + match Repository::open(&repo_root) { + Ok(repo) => checkout_branch(&repo, &name).map_err(|e| e.to_string()), + Err(error) => { + let detail = error.to_string(); + if !should_retry_repository_command(&detail) { + return Err(detail); + } + run_git_command_with_safe_directory(&repo_root, &["checkout", &name]) + .await + .map_err(|fallback_error| { + normalize_repository_command_error(&repo_root, &fallback_error) + }) + } + } } pub(super) async fn create_git_branch_inner( @@ -769,12 +1167,31 @@ pub(super) async fn create_git_branch_inner( ) -> Result<(), String> { let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let head = repo.head().map_err(|e| e.to_string())?; - let target = head.peel_to_commit().map_err(|e| e.to_string())?; - repo.branch(&name, &target, false) - .map_err(|e| e.to_string())?; - checkout_branch(&repo, &name).map_err(|e| e.to_string()) + if prefer_safe_git_cli_repo_access() { + return run_git_command_with_safe_directory(&repo_root, &["checkout", "-b", &name]) + .await + .map_err(|error| normalize_repository_command_error(&repo_root, &error)); + } + match Repository::open(&repo_root) { + Ok(repo) => { + let head = repo.head().map_err(|e| e.to_string())?; + let target = head.peel_to_commit().map_err(|e| e.to_string())?; + repo.branch(&name, &target, false) + .map_err(|e| e.to_string())?; + checkout_branch(&repo, &name).map_err(|e| e.to_string()) + } + Err(error) => { + let detail = error.to_string(); + if !should_retry_repository_command(&detail) { + return Err(detail); + } + run_git_command_with_safe_directory(&repo_root, &["checkout", "-b", &name]) + .await + .map_err(|fallback_error| { + normalize_repository_command_error(&repo_root, &fallback_error) + }) + } + } } #[cfg(test)] diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 0c6af07e8..f0237940b 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -16,11 +16,13 @@ use crate::shared::process_core::std_command; use crate::types::{AppSettings, GitCommitDiff, GitFileDiff, GitFileStatus, WorkspaceEntry}; use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; +use super::commands::normalize_repository_access_error; use super::context::workspace_entry_for_id; const INDEX_SKIP_WORKTREE_FLAG: u16 = 0x4000; const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; const MAX_TEXT_DIFF_BYTES: usize = 2 * 1024 * 1024; +const MAX_RENDERABLE_TEXT_DIFF_BYTES: u64 = 256 * 1024; fn encode_image_base64(data: &[u8]) -> Option { if data.len() > MAX_IMAGE_BYTES { @@ -297,7 +299,8 @@ fn build_combined_diff(repo: &Repository, diff: &git2::Diff) -> String { } pub(super) fn collect_workspace_diff(repo_root: &Path) -> Result { - let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + let repo = Repository::open(repo_root) + .map_err(|e| normalize_repository_access_error(repo_root, &e.to_string()))?; let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); let mut options = DiffOptions::new(); @@ -337,7 +340,8 @@ pub(super) async fn get_git_status_inner( ) -> Result { let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let repo = Repository::open(&repo_root) + .map_err(|e| normalize_repository_access_error(&repo_root, &e.to_string()))?; let branch_name = repo .head() @@ -475,7 +479,8 @@ pub(super) async fn get_git_diffs_inner( }; tokio::task::spawn_blocking(move || { - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let repo = Repository::open(&repo_root) + .map_err(|e| normalize_repository_access_error(&repo_root, &e.to_string()))?; let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); let mut options = DiffOptions::new(); @@ -520,8 +525,25 @@ pub(super) async fn get_git_diffs_inner( let is_image = old_image_mime.is_some() || new_image_mime.is_some(); let is_deleted = delta.status() == git2::Delta::Deleted; let is_added = delta.status() == git2::Delta::Added; - - let old_lines = if !is_added { + let old_blob_size = if !is_added { + head_tree + .as_ref() + .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .map(|blob| blob.size() as u64) + } else { + None + }; + let worktree_file_size = fs::metadata(repo_root.join(display_path)) + .ok() + .map(|meta| meta.len()); + let is_diff_too_large = !is_image + && old_blob_size + .into_iter() + .chain(worktree_file_size) + .any(|size| size > MAX_RENDERABLE_TEXT_DIFF_BYTES); + + let old_lines = if !is_diff_too_large && !is_added { head_tree .as_ref() .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) @@ -531,7 +553,7 @@ pub(super) async fn get_git_diffs_inner( None }; - let new_lines = if !is_deleted { + let new_lines = if !is_diff_too_large && !is_deleted { match new_path { Some(path) => { let full_path = repo_root.join(path); @@ -573,6 +595,7 @@ pub(super) async fn get_git_diffs_inner( new_lines: None, is_binary: true, is_image: true, + is_diff_too_large: false, old_image_data, new_image_data, old_image_mime: old_image_mime.map(str::to_string), @@ -581,6 +604,23 @@ pub(super) async fn get_git_diffs_inner( continue; } + if is_diff_too_large { + results.push(GitFileDiff { + path: normalized_path, + diff: String::new(), + old_lines: None, + new_lines: None, + is_binary: false, + is_image: false, + is_diff_too_large: true, + old_image_data: None, + new_image_data: None, + old_image_mime: None, + new_image_mime: None, + }); + continue; + } + let patch = match git2::Patch::from_diff(&diff, index) { Ok(patch) => patch, Err(_) => continue, @@ -602,6 +642,7 @@ pub(super) async fn get_git_diffs_inner( new_lines, is_binary: false, is_image: false, + is_diff_too_large: false, old_image_data: None, new_image_data: None, old_image_mime: None, @@ -629,7 +670,8 @@ pub(super) async fn get_git_commit_diff_inner( }; let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let repo = Repository::open(&repo_root) + .map_err(|e| normalize_repository_access_error(&repo_root, &e.to_string()))?; let oid = git2::Oid::from_str(&sha).map_err(|e| e.to_string())?; let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; let commit_tree = commit.tree().map_err(|e| e.to_string())?; @@ -658,8 +700,30 @@ pub(super) async fn get_git_commit_diff_inner( let is_image = old_image_mime.is_some() || new_image_mime.is_some(); let is_deleted = delta.status() == git2::Delta::Deleted; let is_added = delta.status() == git2::Delta::Added; + let old_blob_size = if !is_added { + parent_tree + .as_ref() + .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .map(|blob| blob.size() as u64) + } else { + None + }; + let new_blob_size = if !is_deleted { + new_path + .and_then(|path| commit_tree.get_path(path).ok()) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .map(|blob| blob.size() as u64) + } else { + None + }; + let is_diff_too_large = !is_image + && old_blob_size + .into_iter() + .chain(new_blob_size) + .any(|size| size > MAX_RENDERABLE_TEXT_DIFF_BYTES); - let old_lines = if !is_added { + let old_lines = if !is_diff_too_large && !is_added { parent_tree .as_ref() .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) @@ -669,7 +733,7 @@ pub(super) async fn get_git_commit_diff_inner( None }; - let new_lines = if !is_deleted { + let new_lines = if !is_diff_too_large && !is_deleted { new_path .and_then(|path| commit_tree.get_path(path).ok()) .and_then(|entry| repo.find_blob(entry.id()).ok()) @@ -706,6 +770,7 @@ pub(super) async fn get_git_commit_diff_inner( new_lines: None, is_binary: true, is_image: true, + is_diff_too_large: false, old_image_data, new_image_data, old_image_mime: old_image_mime.map(str::to_string), @@ -714,6 +779,24 @@ pub(super) async fn get_git_commit_diff_inner( continue; } + if is_diff_too_large { + results.push(GitCommitDiff { + path: normalized_path, + status: status_for_delta(delta.status()).to_string(), + diff: String::new(), + old_lines: None, + new_lines: None, + is_binary: false, + is_image: false, + is_diff_too_large: true, + old_image_data: None, + new_image_data: None, + old_image_mime: None, + new_image_mime: None, + }); + continue; + } + let patch = match git2::Patch::from_diff(&diff, index) { Ok(patch) => patch, Err(_) => continue, @@ -736,6 +819,7 @@ pub(super) async fn get_git_commit_diff_inner( new_lines, is_binary: false, is_image: false, + is_diff_too_large: false, old_image_data: None, new_image_data: None, old_image_mime: None, diff --git a/src-tauri/src/shared/git_ui_core/github.rs b/src-tauri/src/shared/git_ui_core/github.rs index a3ba384b0..bbec6fe57 100644 --- a/src-tauri/src/shared/git_ui_core/github.rs +++ b/src-tauri/src/shared/git_ui_core/github.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::path::Path; -use git2::Repository; use tokio::sync::Mutex; use crate::git_utils::{parse_github_repo, resolve_git_root}; @@ -12,22 +11,15 @@ use crate::types::{ }; use crate::utils::normalize_git_path; +use super::commands::{git_remote_url, preferred_git_remote_name}; use super::context::workspace_entry_for_id; fn github_repo_from_path(path: &Path) -> Result { - let repo = Repository::open(path).map_err(|e| e.to_string())?; - let remotes = repo.remotes().map_err(|e| e.to_string())?; - let name = if remotes.iter().any(|remote| remote == Some("origin")) { - "origin".to_string() - } else { - remotes.iter().flatten().next().unwrap_or("").to_string() - }; - if name.is_empty() { + let Some(name) = preferred_git_remote_name(path)? else { return Err("No git remote configured.".to_string()); - } - let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; - let remote_url = remote.url().ok_or("Remote has no URL configured.")?; - parse_github_repo(remote_url).ok_or("Remote is not a GitHub repository.".to_string()) + }; + let remote_url = git_remote_url(path, &name).ok_or("Remote has no URL configured.")?; + parse_github_repo(&remote_url).ok_or("Remote is not a GitHub repository.".to_string()) } fn parse_pr_diff(diff: &str) -> Vec { diff --git a/src-tauri/src/shared/git_ui_core/log.rs b/src-tauri/src/shared/git_ui_core/log.rs index 4d2d48e7c..f1654760a 100644 --- a/src-tauri/src/shared/git_ui_core/log.rs +++ b/src-tauri/src/shared/git_ui_core/log.rs @@ -4,10 +4,105 @@ use git2::{BranchType, Repository, Sort}; use tokio::sync::Mutex; use crate::git_utils::{commit_to_entry, resolve_git_root}; -use crate::types::{GitLogResponse, WorkspaceEntry}; +use crate::types::{GitLogEntry, GitLogResponse, WorkspaceEntry}; +use super::commands::{ + git_remote_url, normalize_repository_access_error, normalized_repo_root_for_safe_directory, + prefer_safe_git_cli_repo_access, +}; use super::context::workspace_entry_for_id; +fn parse_git_log_entries(stdout: &str) -> Vec { + stdout + .lines() + .filter_map(|line| { + let mut parts = line.split('\u{1f}'); + let sha = parts.next()?.trim(); + let summary = parts.next()?.trim(); + let author = parts.next()?.trim(); + let timestamp = parts.next()?.trim().parse::().ok()?; + if sha.is_empty() { + return None; + } + Some(GitLogEntry { + sha: sha.to_string(), + summary: summary.to_string(), + author: author.to_string(), + timestamp, + }) + }) + .collect() +} + +async fn git_log_entries( + repo_root: &std::path::Path, + range: Option<&str>, + limit: usize, +) -> Result, String> { + let git_bin = + crate::utils::resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let safe_directory = format!( + "safe.directory={}", + normalized_repo_root_for_safe_directory(repo_root) + ); + let mut command = crate::shared::process_core::tokio_command(git_bin); + command.arg("-c").arg(&safe_directory).arg("log"); + if let Some(range) = range { + command.arg(range); + } + let output = command + .arg("--format=%H%x1f%s%x1f%an%x1f%ct") + .arg("-n") + .arg(limit.to_string()) + .current_dir(repo_root) + .env("PATH", crate::utils::git_env_path()) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }); + } + Ok(parse_git_log_entries(&String::from_utf8_lossy( + &output.stdout, + ))) +} + +async fn git_count(repo_root: &std::path::Path, args: &[&str]) -> Result { + let git_bin = + crate::utils::resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let output = crate::shared::process_core::tokio_command(git_bin) + .arg("-c") + .arg(format!( + "safe.directory={}", + normalized_repo_root_for_safe_directory(repo_root) + )) + .args(args) + .current_dir(repo_root) + .env("PATH", crate::utils::git_env_path()) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }); + } + Ok(String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .unwrap_or(0)) +} + pub(super) async fn get_git_log_inner( workspaces: &Mutex>, workspace_id: String, @@ -15,8 +110,67 @@ pub(super) async fn get_git_log_inner( ) -> Result { let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let max_items = limit.unwrap_or(40); + + if prefer_safe_git_cli_repo_access() { + let total = git_count(&repo_root, &["rev-list", "--count", "HEAD"]).await?; + let entries = git_log_entries(&repo_root, None, max_items).await?; + let upstream = match crate::utils::resolve_git_binary() { + Ok(git_bin) => { + let output = crate::shared::process_core::tokio_command(git_bin) + .arg("-c") + .arg(format!( + "safe.directory={}", + normalized_repo_root_for_safe_directory(&repo_root) + )) + .args([ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]) + .current_dir(&repo_root) + .env("PATH", crate::utils::git_env_path()) + .output() + .await + .ok(); + output.and_then(|output| { + if output.status.success() { + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!value.is_empty()).then_some(value) + } else { + None + } + }) + } + Err(_) => None, + }; + let ahead = git_count(&repo_root, &["rev-list", "--count", "@{upstream}..HEAD"]) + .await + .unwrap_or(0); + let behind = git_count(&repo_root, &["rev-list", "--count", "HEAD..@{upstream}"]) + .await + .unwrap_or(0); + let ahead_entries = git_log_entries(&repo_root, Some("@{upstream}..HEAD"), max_items) + .await + .unwrap_or_default(); + let behind_entries = git_log_entries(&repo_root, Some("HEAD..@{upstream}"), max_items) + .await + .unwrap_or_default(); + + return Ok(GitLogResponse { + total, + entries, + ahead, + behind, + ahead_entries, + behind_entries, + upstream, + }); + } + + let repo = Repository::open(&repo_root) + .map_err(|e| normalize_repository_access_error(&repo_root, &e.to_string()))?; let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; revwalk.push_head().map_err(|e| e.to_string())?; revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; @@ -106,16 +260,6 @@ pub(super) async fn get_git_remote_inner( ) -> Result, String> { let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let remotes = repo.remotes().map_err(|e| e.to_string())?; - let name = if remotes.iter().any(|remote| remote == Some("origin")) { - "origin".to_string() - } else { - remotes.iter().flatten().next().unwrap_or("").to_string() - }; - if name.is_empty() { - return Ok(None); - } - let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; - Ok(remote.url().map(|url| url.to_string())) + let name = super::commands::preferred_git_remote_name(&repo_root)?; + Ok(name.and_then(|remote| git_remote_url(&repo_root, &remote))) } diff --git a/src-tauri/src/shared/git_ui_core/tests.rs b/src-tauri/src/shared/git_ui_core/tests.rs index 3afcb120b..0284313d3 100644 --- a/src-tauri/src/shared/git_ui_core/tests.rs +++ b/src-tauri/src/shared/git_ui_core/tests.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Mutex as StdMutex, OnceLock}; use git2::Repository; use serde_json::Value; @@ -19,6 +20,11 @@ fn create_temp_repo() -> (PathBuf, Repository) { (root, repo) } +fn git_cli_env_lock() -> &'static StdMutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| StdMutex::new(())) +} + #[test] fn collect_workspace_diff_prefers_staged_changes() { let (root, repo) = create_temp_repo(); @@ -114,6 +120,164 @@ fn validate_normalized_repo_name_accepts_non_empty_normalized_slug() { ); } +#[test] +fn parse_git_branch_listing_reads_branch_names_and_timestamps() { + let branches = + commands::parse_git_branch_listing("feature/b\t100\nmain\t250\nstale\t0\ninvalid\tnope\n"); + + assert_eq!(branches[0].name, "main"); + assert_eq!(branches[0].last_commit, 250); + assert_eq!(branches[1].name, "feature/b"); + assert_eq!(branches[1].last_commit, 100); + assert_eq!(branches[3].name, "invalid"); + assert_eq!(branches[3].last_commit, 0); +} + +#[test] +fn detects_repository_owner_errors_from_git_and_libgit2_messages() { + assert!(commands::is_repository_owner_error( + "repository path 'F:/repo' is not owned by current user; class=Config (7); code=Owner (-36)" + )); + assert!(commands::is_repository_owner_error( + "fatal: detected dubious ownership in repository at '/tmp/repo'" + )); + assert!(!commands::is_repository_owner_error( + "fatal: not a git repository (or any of the parent directories): .git" + )); +} + +#[test] +fn detects_repository_missing_errors_from_git_and_libgit2_messages() { + assert!(commands::is_repository_missing_error( + "could not find repository at 'F:\\CODE\\daily'; class=Repository (6); code=NotFound (-3)" + )); + assert!(commands::is_repository_missing_error( + "fatal: not a git repository (or any of the parent directories): .git" + )); + assert!(commands::is_repository_missing_error( + "fatal: cannot change to 'F:/CODE/missing': No such file or directory" + )); + assert!(!commands::is_repository_missing_error( + "repository path 'F:/repo' is not owned by current user; class=Config (7); code=Owner (-36)" + )); +} + +#[test] +fn list_git_branches_supports_cli_first_repo_access_mode() { + let _guard = git_cli_env_lock().lock().expect("env lock"); + let (root, repo) = create_temp_repo(); + fs::write(root.join("tracked.txt"), "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("tracked.txt")).expect("add path"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + let default_branch = repo + .head() + .expect("head") + .shorthand() + .expect("branch name") + .to_string(); + + let head_commit = repo + .head() + .expect("head") + .peel_to_commit() + .expect("head commit"); + repo.branch("feature/cli", &head_commit, false) + .expect("create branch"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + + unsafe { + std::env::set_var("CODEX_MONITOR_FORCE_SAFE_GIT_CLI", "1"); + } + + let runtime = Runtime::new().expect("create tokio runtime"); + let result = runtime + .block_on(commands::list_git_branches_inner( + &workspaces, + "w1".to_string(), + )) + .expect("list branches"); + + unsafe { + std::env::remove_var("CODEX_MONITOR_FORCE_SAFE_GIT_CLI"); + } + + let branches = result + .get("branches") + .and_then(Value::as_array) + .expect("branches array"); + let names: Vec<&str> = branches + .iter() + .filter_map(|entry| entry.get("name").and_then(Value::as_str)) + .collect(); + assert!(names.iter().any(|name| *name == default_branch.as_str())); + assert!(names.contains(&"feature/cli")); +} + +#[test] +fn get_git_log_supports_cli_first_repo_access_mode() { + let _guard = git_cli_env_lock().lock().expect("env lock"); + let (root, repo) = create_temp_repo(); + fs::write(root.join("tracked.txt"), "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("tracked.txt")).expect("add path"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + + unsafe { + std::env::set_var("CODEX_MONITOR_FORCE_SAFE_GIT_CLI", "1"); + } + + let runtime = Runtime::new().expect("create tokio runtime"); + let result = runtime + .block_on(super::log::get_git_log_inner( + &workspaces, + "w1".to_string(), + Some(10), + )) + .expect("git log"); + + unsafe { + std::env::remove_var("CODEX_MONITOR_FORCE_SAFE_GIT_CLI"); + } + + assert_eq!(result.total, 1); + assert_eq!(result.entries.len(), 1); + assert_eq!(result.entries[0].summary, "init"); +} + #[test] fn get_git_status_omits_global_ignored_paths() { let (root, repo) = create_temp_repo(); @@ -226,6 +390,63 @@ fn get_git_diffs_omits_global_ignored_paths() { assert!(!has_ignored, "ignored files should not appear in diff list"); } +#[test] +fn get_git_diffs_marks_large_text_files_as_too_large() { + let (root, repo) = create_temp_repo(); + let large_path = root.join("dist").join("bundle.js"); + fs::create_dir_all(large_path.parent().expect("parent")).expect("create dist dir"); + fs::write(&large_path, "const version = 1;\n").expect("write initial bundle"); + + let mut index = repo.index().expect("repo index"); + index + .add_path(Path::new("dist/bundle.js")) + .expect("add bundle"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + let large_content = format!("{}\n", "x".repeat(300 * 1024)); + fs::write(&large_path, large_content).expect("overwrite bundle with large output"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + let app_settings = Mutex::new(AppSettings::default()); + + let runtime = Runtime::new().expect("create tokio runtime"); + let diffs = runtime + .block_on(diff::get_git_diffs_inner( + &workspaces, + &app_settings, + "w1".to_string(), + )) + .expect("get git diffs"); + + let large_diff = diffs + .iter() + .find(|diff| diff.path == "dist/bundle.js") + .expect("bundle diff"); + + assert!( + large_diff.is_diff_too_large, + "large text outputs should be marked as too large for safe rendering" + ); + assert!(large_diff.diff.is_empty(), "large text diff content should be omitted"); + assert!(large_diff.old_lines.is_none(), "large diff should not carry old lines"); + assert!(large_diff.new_lines.is_none(), "large diff should not carry new lines"); +} + #[test] fn check_ignore_with_git_respects_negated_rule_for_specific_file() { let (root, repo) = create_temp_repo(); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 6b595106b..85f3bf1ed 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -20,6 +20,8 @@ pub(crate) struct GitFileDiff { pub(crate) is_binary: bool, #[serde(default, rename = "isImage")] pub(crate) is_image: bool, + #[serde(default, rename = "isDiffTooLarge")] + pub(crate) is_diff_too_large: bool, #[serde(rename = "oldImageData")] pub(crate) old_image_data: Option, #[serde(rename = "newImageData")] @@ -43,6 +45,8 @@ pub(crate) struct GitCommitDiff { pub(crate) is_binary: bool, #[serde(default, rename = "isImage")] pub(crate) is_image: bool, + #[serde(default, rename = "isDiffTooLarge")] + pub(crate) is_diff_too_large: bool, #[serde(rename = "oldImageData")] pub(crate) old_image_data: Option, #[serde(rename = "newImageData")] @@ -648,6 +652,8 @@ pub(crate) struct AppSettings { pub(crate) open_app_targets: Vec, #[serde(default = "default_selected_open_app_id", rename = "selectedOpenAppId")] pub(crate) selected_open_app_id: String, + #[serde(default, rename = "language")] + pub(crate) language: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -1199,6 +1205,7 @@ impl Default for AppSettings { global_worktrees_folder: None, open_app_targets: default_open_app_targets(), selected_open_app_id: default_selected_open_app_id(), + language: None, } } } @@ -1366,6 +1373,7 @@ mod tests { assert_eq!(settings.selected_open_app_id, expected_open_id); assert_eq!(settings.open_app_targets.len(), 6); assert_eq!(settings.open_app_targets[0].id, "vscode"); + assert!(settings.language.is_none()); } #[test] diff --git a/src/features/app/components/AppChromeI18n.test.tsx b/src/features/app/components/AppChromeI18n.test.tsx new file mode 100644 index 000000000..f08e1fc0c --- /dev/null +++ b/src/features/app/components/AppChromeI18n.test.tsx @@ -0,0 +1,127 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from "@testing-library/react"; +import i18n from "i18next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarBottomRail } from "./SidebarBottomRail"; +import { LaunchScriptButton } from "./LaunchScriptButton"; +import { ComposerMetaBar } from "../../composer/components/ComposerMetaBar"; +import { TerminalDock } from "../../terminal/components/TerminalDock"; + +describe("app chrome i18n", () => { + beforeEach(async () => { + await i18n.changeLanguage("zh"); + }); + + afterEach(async () => { + cleanup(); + await i18n.changeLanguage("en"); + }); + + it("localizes sidebar hover labels in Chinese", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "账户" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "打开设置" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "打开调试日志" })).toBeTruthy(); + }); + + it("localizes launch and terminal controls in Chinese", () => { + const { container } = render( + <> + + terminal} + /> + , + ); + + expect(screen.getByRole("button", { name: "设置启动脚本" })).toBeTruthy(); + expect(screen.getByPlaceholderText("例如 npm run dev")).toBeTruthy(); + expect(screen.getByRole("tablist", { name: "终端标签页" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "新建终端" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "关闭 shell" })).toBeTruthy(); + + const resizer = container.querySelector(".terminal-panel-resizer"); + expect(resizer?.getAttribute("aria-label")).toBe("调整终端面板大小"); + }); + + it("localizes the context-free tooltip in Chinese", () => { + const { container } = render( + , + ); + + const ring = container.querySelector(".composer-context-ring"); + expect(ring?.getAttribute("data-tooltip")).toBe("上下文空余 75%"); + expect(ring?.getAttribute("aria-label")).toBe("上下文空余 75%"); + }); +}); diff --git a/src/features/app/components/LaunchScriptButton.tsx b/src/features/app/components/LaunchScriptButton.tsx index 8e2d82f2d..4ab3779bc 100644 --- a/src/features/app/components/LaunchScriptButton.tsx +++ b/src/features/app/components/LaunchScriptButton.tsx @@ -1,4 +1,5 @@ import Play from "lucide-react/dist/esm/icons/play"; +import { useTranslation } from "react-i18next"; import type { LaunchScriptIconId } from "../../../types"; import { PopoverSurface } from "../../design-system/components/popover/PopoverPrimitives"; import { useMenuController } from "../hooks/useMenuController"; @@ -54,6 +55,7 @@ export function LaunchScriptButton({ onNewDraftLabelChange, onCreateNew, }: LaunchScriptButtonProps) { + const { t } = useTranslation(); const editorMenu = useMenuController({ open: editorOpen, onDismiss: () => { @@ -76,9 +78,21 @@ export function LaunchScriptButton({ onOpenEditor(); }} data-tauri-drag-region="false" - aria-label={hasLaunchScript ? "Run launch script" : "Set launch script"} - title={hasLaunchScript ? "Run launch script" : "Set launch script"} - data-tooltip={hasLaunchScript ? "Run launch script" : "Set launch script"} + aria-label={ + hasLaunchScript + ? t("uiText.appHeader.runLaunchScript") + : t("uiText.appHeader.setLaunchScript") + } + title={ + hasLaunchScript + ? t("uiText.appHeader.runLaunchScript") + : t("uiText.appHeader.setLaunchScript") + } + data-tooltip={ + hasLaunchScript + ? t("uiText.appHeader.runLaunchScript") + : t("uiText.appHeader.setLaunchScript") + } data-tooltip-placement="bottom" > @@ -86,10 +100,10 @@ export function LaunchScriptButton({ {editorOpen && ( -
Launch script
+
{t("uiText.appHeader.launchScript")}