diff --git a/.gitignore b/.gitignore index de2b67db..fdc306f4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ /apps/desktop/runtime/target/ /apps/desktop/runtime/Cargo.lock /plan/ +/.codux/ +/.cursor/ +/.omo/ # Monorepo app build outputs /apps/mobile/.dart_tool/ diff --git a/apps/desktop/runtime-assets/i18n/locales/settings.json b/apps/desktop/runtime-assets/i18n/locales/settings.json index b72ac255..f786d126 100644 --- a/apps/desktop/runtime-assets/i18n/locales/settings.json +++ b/apps/desktop/runtime-assets/i18n/locales/settings.json @@ -375,7 +375,12 @@ "settings.ai.tool.reasoning_effort.low": "Niedrig", "settings.ai.tool.reasoning_effort.medium": "Mittel", "settings.ai.tool.reasoning_effort.high": "Hoch", - "settings.ai.tool.reasoning_effort.xhigh": "Sehr hoch" + "settings.ai.tool.reasoning_effort.xhigh": "Sehr hoch", + "settings.git.file_panel": "Dateipanel", + "settings.git.file_view_mode": "Layout", + "settings.git.file_view_mode.help": "Baum gruppiert geänderte Dateien nach Verzeichnis. Flach listet sie direkt unter jedem Abschnitt auf.", + "settings.git.review_compare_mode": "Review-Vergleich", + "settings.git.review_compare_mode.help": "Arbeitsbaum prüft nur nicht committete Änderungen. Origin zu Arbeitsbaum zeigt alles, was nicht hochgeladen wurde." }, "en": { "settings.ai_auto_refresh": "AI Auto Refresh", @@ -754,7 +759,12 @@ "settings.ai.tool.reasoning_effort.low": "Low", "settings.ai.tool.reasoning_effort.medium": "Medium", "settings.ai.tool.reasoning_effort.high": "High", - "settings.ai.tool.reasoning_effort.xhigh": "XHigh" + "settings.ai.tool.reasoning_effort.xhigh": "XHigh", + "settings.git.file_panel": "File Panel", + "settings.git.file_view_mode": "Layout", + "settings.git.file_view_mode.help": "Tree groups changed files by directory. Flatten lists them directly under each section.", + "settings.git.review_compare_mode": "Review Compare", + "settings.git.review_compare_mode.help": "Working Tree audits only uncommitted changes. Origin to Working Tree shows everything not pushed upstream." }, "es": { "settings.ai_auto_refresh": "Actualización automática de IA", @@ -1131,7 +1141,12 @@ "settings.ai.tool.reasoning_effort.low": "Bajo", "settings.ai.tool.reasoning_effort.medium": "Medio", "settings.ai.tool.reasoning_effort.high": "Alto", - "settings.ai.tool.reasoning_effort.xhigh": "Muy alto" + "settings.ai.tool.reasoning_effort.xhigh": "Muy alto", + "settings.git.file_panel": "Panel de archivos", + "settings.git.file_view_mode": "Diseño", + "settings.git.file_view_mode.help": "Árbol agrupa los archivos modificados por directorio. Plano los lista directamente bajo cada sección.", + "settings.git.review_compare_mode": "Comparación de revisión", + "settings.git.review_compare_mode.help": "Árbol de trabajo audita solo cambios no confirmados. Origin a árbol de trabajo muestra todo lo no empujado." }, "fr": { "settings.ai_auto_refresh": "Actualisation auto IA", @@ -1508,7 +1523,12 @@ "settings.ai.tool.reasoning_effort.low": "Faible", "settings.ai.tool.reasoning_effort.medium": "Moyen", "settings.ai.tool.reasoning_effort.high": "Élevé", - "settings.ai.tool.reasoning_effort.xhigh": "Très élevé" + "settings.ai.tool.reasoning_effort.xhigh": "Très élevé", + "settings.git.file_panel": "Panneau de fichiers", + "settings.git.file_view_mode": "Disposition", + "settings.git.file_view_mode.help": "Arborescence regroupe les fichiers modifiés par répertoire. Liste les affiche directement sous chaque section.", + "settings.git.review_compare_mode": "Comparaison de révision", + "settings.git.review_compare_mode.help": "Arbre de travail audite uniquement les modifications non validées. Origin vers arbre de travail montre tout ce qui n'est pas poussé." }, "ja": { "settings.ai_auto_refresh": "AI 自動更新", @@ -1885,7 +1905,12 @@ "settings.ai.tool.reasoning_effort.low": "低", "settings.ai.tool.reasoning_effort.medium": "中", "settings.ai.tool.reasoning_effort.high": "高", - "settings.ai.tool.reasoning_effort.xhigh": "最高" + "settings.ai.tool.reasoning_effort.xhigh": "最高", + "settings.git.file_panel": "ファイルパネル", + "settings.git.file_view_mode": "レイアウト", + "settings.git.file_view_mode.help": "ツリーは変更されたファイルをディレクトリ別にグループ化します。フラットは各セクションの下に直接表示します。", + "settings.git.review_compare_mode": "レビュー比較", + "settings.git.review_compare_mode.help": "ワーキングツリーはコミットされていない変更のみ監査します。Origin to Working Treeはプッシュされていないすべてを表示します。" }, "ko": { "settings.ai_auto_refresh": "AI 자동 새로고침", @@ -2262,7 +2287,12 @@ "settings.ai.tool.reasoning_effort.low": "낮음", "settings.ai.tool.reasoning_effort.medium": "중간", "settings.ai.tool.reasoning_effort.high": "높음", - "settings.ai.tool.reasoning_effort.xhigh": "매우 높음" + "settings.ai.tool.reasoning_effort.xhigh": "매우 높음", + "settings.git.file_panel": "파일 패널", + "settings.git.file_view_mode": "레이아웃", + "settings.git.file_view_mode.help": "트리는 변경된 파일을 디렉토리별로 그룹화합니다. 플랫은 각 섹션 아래에 직접 나열합니다.", + "settings.git.review_compare_mode": "리뷰 비교", + "settings.git.review_compare_mode.help": "워킹 트리는 커밋되지 않은 변경만 감사합니다. Origin to Working Tree는 푸시되지 않은 모든 것을 표시합니다." }, "pt-BR": { "settings.ai_auto_refresh": "Atualização automática de IA", @@ -2639,7 +2669,12 @@ "settings.ai.tool.reasoning_effort.low": "Baixo", "settings.ai.tool.reasoning_effort.medium": "Médio", "settings.ai.tool.reasoning_effort.high": "Alto", - "settings.ai.tool.reasoning_effort.xhigh": "Muito alto" + "settings.ai.tool.reasoning_effort.xhigh": "Muito alto", + "settings.git.file_panel": "Painel de arquivos", + "settings.git.file_view_mode": "Layout", + "settings.git.file_view_mode.help": "Árvore agrupa arquivos alterados por diretório. Plano os lista diretamente sob cada seção.", + "settings.git.review_compare_mode": "Comparação de revisão", + "settings.git.review_compare_mode.help": "Working Tree audita apenas alterações não commitadas. Origin to Working Tree mostra tudo não enviado." }, "ru": { "settings.ai_auto_refresh": "Автообновление ИИ", @@ -3016,7 +3051,12 @@ "settings.ai.tool.reasoning_effort.low": "Низкий", "settings.ai.tool.reasoning_effort.medium": "Средний", "settings.ai.tool.reasoning_effort.high": "Высокий", - "settings.ai.tool.reasoning_effort.xhigh": "Очень высокий" + "settings.ai.tool.reasoning_effort.xhigh": "Очень высокий", + "settings.git.file_panel": "Панель файлов", + "settings.git.file_view_mode": "Макет", + "settings.git.file_view_mode.help": "Дерево группирует изменённые файлы по каталогам. Плоский список показывает их напрямую под каждой секцией.", + "settings.git.review_compare_mode": "Сравнение ревью", + "settings.git.review_compare_mode.help": "Рабочее дерево проверяет только незафиксированные изменения. Origin to Working Tree показывает всё, что не отправлено." }, "zh-Hans": { "settings.ai_auto_refresh": "AI 自动刷新", @@ -3395,7 +3435,12 @@ "settings.ai.tool.reasoning_effort.low": "低", "settings.ai.tool.reasoning_effort.medium": "中", "settings.ai.tool.reasoning_effort.high": "高", - "settings.ai.tool.reasoning_effort.xhigh": "极高" + "settings.ai.tool.reasoning_effort.xhigh": "极高", + "settings.git.file_panel": "文件面板", + "settings.git.file_view_mode": "布局", + "settings.git.file_view_mode.help": "树形按目录分组已更改的文件。扁平在每个部分下直接列出它们。", + "settings.git.review_compare_mode": "审查比较", + "settings.git.review_compare_mode.help": "工作树仅审查未提交的更改。Origin to Working Tree 显示所有未推送到上游的内容。" }, "zh-Hant": { "settings.ai_auto_refresh": "AI 自動重新整理", @@ -3774,7 +3819,12 @@ "settings.ai.tool.reasoning_effort.low": "低", "settings.ai.tool.reasoning_effort.medium": "中", "settings.ai.tool.reasoning_effort.high": "高", - "settings.ai.tool.reasoning_effort.xhigh": "極高" + "settings.ai.tool.reasoning_effort.xhigh": "極高", + "settings.git.file_panel": "檔案面板", + "settings.git.file_view_mode": "佈局", + "settings.git.file_view_mode.help": "樹狀按目錄分組已變更的檔案。扁平在每個區段下直接列出它們。", + "settings.git.review_compare_mode": "審查比較", + "settings.git.review_compare_mode.help": "工作樹僅審查未提交的變更。Origin to Working Tree 顯示所有未推送到上游的內容。" } } } diff --git a/apps/desktop/runtime/src/remote/tests.rs b/apps/desktop/runtime/src/remote/tests.rs index 8e2044c4..6e8e900e 100644 --- a/apps/desktop/runtime/src/remote/tests.rs +++ b/apps/desktop/runtime/src/remote/tests.rs @@ -458,6 +458,11 @@ fn remote_git_status_payload_matches_domain_shape() { index_status: "modified".to_string(), worktree_status: "modified".to_string(), }], + flat_changed_files: vec![crate::git::GitFileStatus { + path: "src/main.rs".to_string(), + index_status: "modified".to_string(), + worktree_status: "modified".to_string(), + }], branches: vec![crate::git::GitBranchSummary { name: "main".to_string(), is_current: true, @@ -490,6 +495,13 @@ fn remote_git_status_payload_matches_domain_shape() { .map(Vec::len), Some(1) ); + assert_eq!( + payload + .get("flatChangedFiles") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(1) + ); } #[test] diff --git a/apps/desktop/runtime/src/runtime_state/service_projects_settings/settings.rs b/apps/desktop/runtime/src/runtime_state/service_projects_settings/settings.rs index b3e6b2ff..c7bcabc9 100644 --- a/apps/desktop/runtime/src/runtime_state/service_projects_settings/settings.rs +++ b/apps/desktop/runtime/src/runtime_state/service_projects_settings/settings.rs @@ -174,6 +174,20 @@ impl RuntimeService { self.update_settings_with_side_effects(|settings| settings.set_git_refresh(seconds)) } + pub fn cycle_git_file_view_mode(&self) -> Result { + self.update_settings_with_side_effects(|settings| settings.cycle_git_file_view_mode()) + } + + pub fn set_git_file_view_mode(&self, mode: &str) -> Result { + self.update_settings_with_side_effects(|settings| settings.set_git_file_view_mode(mode)) + } + + pub fn set_git_review_compare_mode(&self, mode: &str) -> Result { + self.update_settings_with_side_effects(|settings| { + settings.set_git_review_compare_mode(mode) + }) + } + pub fn cycle_ai_refresh(&self) -> Result { self.update_settings_with_side_effects(|settings| settings.cycle_ai_refresh()) } diff --git a/apps/desktop/runtime/src/runtime_state/service_remote_controller.rs b/apps/desktop/runtime/src/runtime_state/service_remote_controller.rs index ac7b34d8..1eee6f3e 100644 --- a/apps/desktop/runtime/src/runtime_state/service_remote_controller.rs +++ b/apps/desktop/runtime/src/runtime_state/service_remote_controller.rs @@ -566,6 +566,7 @@ fn git_summary_from_payload(value: &serde_json::Value) -> crate::git::GitSummary .and_then(Value::as_str) .map(str::to_string), changed_files: parse_typed(value, "changedFiles"), + flat_changed_files: parse_typed(value, "flatChangedFiles"), branches: parse_typed(value, "branches"), remote_branches: parse_typed(value, "remoteBranches"), remotes: parse_typed(value, "remotes"), diff --git a/apps/desktop/runtime/src/settings/app_settings/defaults.rs b/apps/desktop/runtime/src/settings/app_settings/defaults.rs index 88e7e203..be51874a 100644 --- a/apps/desktop/runtime/src/settings/app_settings/defaults.rs +++ b/apps/desktop/runtime/src/settings/app_settings/defaults.rs @@ -195,3 +195,11 @@ pub(super) fn is_managed_update_endpoint(endpoint: &str) -> bool { | "https://raw.githubusercontent.com/duxweb/codux/main/updates/beta/latest.json" ) } + +pub(super) fn default_git_file_view_mode() -> String { + "tree".to_string() +} + +pub(super) fn default_git_review_compare_mode() -> String { + "workingTree".to_string() +} diff --git a/apps/desktop/runtime/src/settings/app_settings/sanitize.rs b/apps/desktop/runtime/src/settings/app_settings/sanitize.rs index a87cdf66..b7eacb81 100644 --- a/apps/desktop/runtime/src/settings/app_settings/sanitize.rs +++ b/apps/desktop/runtime/src/settings/app_settings/sanitize.rs @@ -91,6 +91,14 @@ pub(super) fn sanitize_settings(mut settings: AppSettings) -> AppSettings { } else if settings.update.enabled && is_managed_update_endpoint(&settings.update.endpoint) { settings.update.endpoint = update_endpoint_for_channel(&settings.update.channel); } + settings.git.file_view_mode = match settings.git.file_view_mode.trim() { + "flatten" => "flatten".to_string(), + _ => "tree".to_string(), + }; + settings.git.review_compare_mode = match settings.git.review_compare_mode.trim() { + "originToWorkingTree" => "originToWorkingTree".to_string(), + _ => "workingTree".to_string(), + }; settings } diff --git a/apps/desktop/runtime/src/settings/app_settings/types.rs b/apps/desktop/runtime/src/settings/app_settings/types.rs index ee129c13..9cc96433 100644 --- a/apps/desktop/runtime/src/settings/app_settings/types.rs +++ b/apps/desktop/runtime/src/settings/app_settings/types.rs @@ -64,11 +64,22 @@ pub struct AppSettings { #[serde(default)] pub remote: RemoteSettings, #[serde(default)] + pub git: GitSettings, + #[serde(default)] pub developer_hud: bool, #[serde(default = "default_developer_refresh")] pub developer_refresh: String, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitSettings { + #[serde(default = "default_git_file_view_mode")] + pub file_view_mode: String, + #[serde(default = "default_git_review_compare_mode")] + pub review_compare_mode: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateSettings { diff --git a/apps/desktop/runtime/src/settings/app_settings/types/defaults_impl.rs b/apps/desktop/runtime/src/settings/app_settings/types/defaults_impl.rs index d9eda9bf..123aa143 100644 --- a/apps/desktop/runtime/src/settings/app_settings/types/defaults_impl.rs +++ b/apps/desktop/runtime/src/settings/app_settings/types/defaults_impl.rs @@ -1,3 +1,12 @@ +impl Default for GitSettings { + fn default() -> Self { + Self { + file_view_mode: default_git_file_view_mode(), + review_compare_mode: default_git_review_compare_mode(), + } + } +} + impl Default for RemoteSettings { fn default() -> Self { Self { @@ -152,6 +161,7 @@ impl Default for AppSettings { shortcuts: HashMap::new(), update: UpdateSettings::default(), remote: RemoteSettings::default(), + git: GitSettings::default(), developer_hud: false, developer_refresh: default_developer_refresh(), } diff --git a/apps/desktop/runtime/src/settings/default_summary.rs b/apps/desktop/runtime/src/settings/default_summary.rs index f0f224ab..d96ef3ac 100644 --- a/apps/desktop/runtime/src/settings/default_summary.rs +++ b/apps/desktop/runtime/src/settings/default_summary.rs @@ -75,6 +75,8 @@ impl Default for SettingsSummary { pet_speech_temporary_muted: false, developer_hud: false, developer_refresh: "3".to_string(), + git_file_view_mode: "tree".to_string(), + git_review_compare_mode: "workingTree".to_string(), shortcuts: HashMap::new(), } } diff --git a/apps/desktop/runtime/src/settings/sanitize.rs b/apps/desktop/runtime/src/settings/sanitize.rs index 9a7ba45b..5e0479ff 100644 --- a/apps/desktop/runtime/src/settings/sanitize.rs +++ b/apps/desktop/runtime/src/settings/sanitize.rs @@ -209,3 +209,24 @@ fn pet_mut(raw: &mut Map) -> Result<&mut Map, Stri .as_object_mut() .ok_or_else(|| "Pet settings are invalid.".to_string()) } + +pub(super) fn git_mut(raw: &mut Map) -> Result<&mut Map, String> { + raw.entry("git".to_string()) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| "Git settings are invalid.".to_string()) +} + +pub(super) fn sanitize_git_file_view_mode(value: &str) -> String { + match value.trim() { + "flatten" => "flatten".to_string(), + _ => "tree".to_string(), + } +} + +pub(super) fn sanitize_git_review_compare_mode(value: &str) -> String { + match value.trim() { + "originToWorkingTree" => "originToWorkingTree".to_string(), + _ => "workingTree".to_string(), + } +} diff --git a/apps/desktop/runtime/src/settings/service.rs b/apps/desktop/runtime/src/settings/service.rs index f8fa3692..e8bd0f47 100644 --- a/apps/desktop/runtime/src/settings/service.rs +++ b/apps/desktop/runtime/src/settings/service.rs @@ -28,6 +28,14 @@ impl SettingsService { Ok(summary_from_raw(&raw)) } + pub fn update_git_string(&self, key: &str, value: String) -> Result { + let mut raw = self.raw_settings(); + let git = git_mut(&mut raw)?; + git.insert(key.to_string(), Value::String(value)); + self.save_raw_settings(&raw)?; + Ok(summary_from_raw(&raw)) + } + fn toggle_pet_bool(&self, key: &str, default: bool) -> Result { let mut raw = self.raw_settings(); let pet = pet_mut(&mut raw)?; diff --git a/apps/desktop/runtime/src/settings/service_preferences.rs b/apps/desktop/runtime/src/settings/service_preferences.rs index 447810f9..fc29f653 100644 --- a/apps/desktop/runtime/src/settings/service_preferences.rs +++ b/apps/desktop/runtime/src/settings/service_preferences.rs @@ -1,5 +1,6 @@ include!("service_preferences/appearance.rs"); include!("service_preferences/general.rs"); include!("service_preferences/git_commit.rs"); +include!("service_preferences/git_view.rs"); include!("service_preferences/refresh.rs"); include!("service_preferences/terminal.rs"); diff --git a/apps/desktop/runtime/src/settings/service_preferences/git_view.rs b/apps/desktop/runtime/src/settings/service_preferences/git_view.rs new file mode 100644 index 00000000..454838ac --- /dev/null +++ b/apps/desktop/runtime/src/settings/service_preferences/git_view.rs @@ -0,0 +1,30 @@ +impl SettingsService { + pub fn set_git_file_view_mode(&self, mode: &str) -> Result { + let mode = sanitize_git_file_view_mode(mode); + self.update_git_string("fileViewMode", mode) + } + + pub fn cycle_git_file_view_mode(&self) -> Result { + let current = self.summary().git_file_view_mode; + let next = if current == "flatten" { + "tree" + } else { + "flatten" + }; + self.update_git_string("fileViewMode", next.to_string()) + } + pub fn set_git_review_compare_mode(&self, mode: &str) -> Result { + let mode = sanitize_git_review_compare_mode(mode); + self.update_git_string("reviewCompareMode", mode) + } + + pub fn cycle_git_review_compare_mode(&self) -> Result { + let current = self.summary().git_review_compare_mode; + let next = if current == "originToWorkingTree" { + "workingTree" + } else { + "originToWorkingTree" + }; + self.update_git_string("reviewCompareMode", next.to_string()) + } +} diff --git a/apps/desktop/runtime/src/settings/summary.rs b/apps/desktop/runtime/src/settings/summary.rs index 7269b725..ce776e35 100644 --- a/apps/desktop/runtime/src/settings/summary.rs +++ b/apps/desktop/runtime/src/settings/summary.rs @@ -8,6 +8,7 @@ fn summary_from_raw(raw: &Map) -> SettingsSummary { let remote = raw.get("remote").and_then(Value::as_object); let update = raw.get("update").and_then(Value::as_object); let pet = raw.get("pet").and_then(Value::as_object); + let git = raw.get("git").and_then(Value::as_object); SettingsSummary { language: string_value(raw, "language", defaults.language), @@ -370,6 +371,16 @@ fn summary_from_raw(raw: &Map) -> SettingsSummary { .and_then(Value::as_str) .map(|value| numeric_string(value, 3, 1, 86_400).to_string()) .unwrap_or(defaults.developer_refresh), + git_file_view_mode: git + .and_then(|git| git.get("fileViewMode")) + .and_then(Value::as_str) + .map(sanitize_git_file_view_mode) + .unwrap_or(defaults.git_file_view_mode), + git_review_compare_mode: git + .and_then(|git| git.get("reviewCompareMode")) + .and_then(Value::as_str) + .map(sanitize_git_review_compare_mode) + .unwrap_or(defaults.git_review_compare_mode), shortcuts: raw .get("shortcuts") .and_then(Value::as_object) diff --git a/apps/desktop/runtime/src/settings/types.rs b/apps/desktop/runtime/src/settings/types.rs index aefccd3c..370d0ba5 100644 --- a/apps/desktop/runtime/src/settings/types.rs +++ b/apps/desktop/runtime/src/settings/types.rs @@ -75,6 +75,8 @@ pub struct SettingsSummary { pub pet_speech_temporary_muted: bool, pub developer_hud: bool, pub developer_refresh: String, + pub git_file_view_mode: String, + pub git_review_compare_mode: String, pub shortcuts: HashMap, } diff --git a/apps/desktop/src/app/git_actions.rs b/apps/desktop/src/app/git_actions.rs index 0fc93273..808494fe 100644 --- a/apps/desktop/src/app/git_actions.rs +++ b/apps/desktop/src/app/git_actions.rs @@ -27,18 +27,10 @@ impl CoduxApp { } else { content.head_content.as_str() }; - let new_content = content - .index_content - .as_deref() - .unwrap_or(content.worktree_content.as_str()); let final_content = content.worktree_content.as_str(); - let branch_content = - (self.git_review.mode == "taskBranch").then_some(content.head_content.as_str()); self.git_review_derived_rows = Some(super::sidebars::build_git_review_derived_rows( original_content, - new_content, final_content, - branch_content, &content.deleted_lines, &content.added_lines, )); @@ -768,7 +760,7 @@ impl CoduxApp { let Some(scope_key) = super::app_state::current_worktree_scope_key(&self.state) else { return; }; - let base_branch = self.git_review.base_branch.clone(); + let base_branch = self.effective_review_base_branch(); let runtime_service = self.runtime_service.clone(); let generation = self.project_switch_generation; self.selected_git_file = Some(file_path.clone()); @@ -915,7 +907,7 @@ impl CoduxApp { ); let view = cx.new(|_| app); let service = view.read(cx).runtime_service.clone(); - let base_branch = view.read(cx).git_review.base_branch.clone(); + let base_branch = view.read(cx).effective_review_base_branch(); let diff_project_path = project_path.clone(); let diff_selected_file = selected_file.clone(); view.update(cx, |_app, cx| { @@ -991,13 +983,52 @@ impl CoduxApp { } } + pub(super) fn effective_review_base_branch(&self) -> Option { + if self.state.settings.git_review_compare_mode == "originToWorkingTree" { + let upstream = self.state.git.upstream.as_deref(); + if upstream.map(|value| !value.is_empty()).unwrap_or(false) { + return upstream.map(|value| value.to_string()); + } + for candidate in ["origin/main", "origin/master"] { + if self.state.git.remote_branches.iter().any(|branch| branch == candidate) { + return Some(candidate.to_string()); + } + } + None + } else { + None + } + } + pub(super) fn refresh_git_review_for_project(&mut self, project_path: &str) { + let base_branch = self.effective_review_base_branch(); self.git_review = self .runtime_service - .reload_project_git_review(project_path, self.git_review.base_branch.as_deref()); + .reload_project_git_review(project_path, base_branch.as_deref()); merge_git_review_status_files(&mut self.git_review, &self.state.git); } + pub(super) fn reload_git_review_for_compare_mode_change(&mut self, cx: &mut Context) { + let Some(project_path) = self.selected_worktree_path() else { + return; + }; + self.refresh_git_review_for_project(&project_path); + if let Some(file_path) = self.selected_git_file.clone() { + let base_branch = self.effective_review_base_branch(); + self.git_diff_preview = self + .runtime_service + .read_project_git_review_diff(&project_path, &file_path, base_branch.as_deref()) + .unwrap_or_else(|error| format!("failed to reload diff: {error}")); + let content = self.runtime_service.read_project_git_review_file_content( + &project_path, + &file_path, + base_branch.as_deref(), + ); + self.set_git_review_derived_content(content); + } + self.invalidate_git_panel(cx); + } + pub(super) fn normalize_selected_git_branch(&mut self) { let selected_still_exists = self .selected_git_branch @@ -1745,18 +1776,19 @@ impl CoduxApp { } else if let Some(file_path) = completion.diff_file_to_reload.as_deref() && self.selected_git_file.is_some() { + let base_branch = self.effective_review_base_branch(); self.git_diff_preview = self .runtime_service .read_project_git_review_diff( &project_path, file_path, - self.git_review.base_branch.as_deref(), + base_branch.as_deref(), ) .unwrap_or_else(|error| format!("failed to reload diff: {error}")); let content = self.runtime_service.read_project_git_review_file_content( &project_path, file_path, - self.git_review.base_branch.as_deref(), + base_branch.as_deref(), ); self.set_git_review_derived_content(content); } diff --git a/apps/desktop/src/app/project_actions.rs b/apps/desktop/src/app/project_actions.rs index 296783f8..8eb1bb85 100644 --- a/apps/desktop/src/app/project_actions.rs +++ b/apps/desktop/src/app/project_actions.rs @@ -117,7 +117,7 @@ impl CoduxApp { let Some(scope_key) = current_worktree_scope_key(&self.state) else { return; }; - let base_branch = self.git_review.base_branch.clone(); + let base_branch = self.effective_review_base_branch(); let runtime_service = self.runtime_service.clone(); let generation = self.project_switch_generation; self.git_review_refreshing = true; diff --git a/apps/desktop/src/app/settings.rs b/apps/desktop/src/app/settings.rs index e1233ca0..69d1de7a 100644 --- a/apps/desktop/src/app/settings.rs +++ b/apps/desktop/src/app/settings.rs @@ -2983,6 +2983,72 @@ fn settings_git_pane( ) -> AnyElement { let language = settings.language.as_str(); settings_form(vec![ + settings_card( + Some(settings_text( + language, + "settings.git.file_panel", + "File Panel", + )), + None, + vec![ + settings_row( + settings_text( + language, + "settings.git.file_view_mode", + "Layout", + ), + Some(settings_text( + language, + "settings.git.file_view_mode.help", + "Tree groups changed files by directory. Flatten lists them directly under each section.", + )), + settings_select_impl( + "settings-git-file-view-mode", + &settings.git_file_view_mode, + vec![ + opt("tree", "Tree"), + opt("flatten", "Flatten"), + ], + window, + cx, + language, + |app, value, window, cx| { + app.set_git_file_view_mode(value, window, cx) + }, + ), + ) + .into_any_element(), + settings_row( + settings_text( + language, + "settings.git.review_compare_mode", + "Review Compare", + ), + Some(settings_text( + language, + "settings.git.review_compare_mode.help", + "Working Tree audits only uncommitted changes. Origin to Working Tree shows everything not pushed upstream.", + )), + settings_select_impl( + "settings-git-review-compare-mode", + &settings.git_review_compare_mode, + vec![ + opt("workingTree", "Working Tree"), + opt("originToWorkingTree", "Origin to Working Tree"), + ], + window, + cx, + language, + |app, value, _window, cx| { + app.set_git_review_compare_mode(value, cx) + }, + ), + ) + .into_any_element(), + ], + cx, + ) + .into_any_element(), settings_card( Some(settings_text( language, diff --git a/apps/desktop/src/app/settings_actions.rs b/apps/desktop/src/app/settings_actions.rs index 92b0f23e..23f9e34f 100644 --- a/apps/desktop/src/app/settings_actions.rs +++ b/apps/desktop/src/app/settings_actions.rs @@ -674,6 +674,54 @@ impl CoduxApp { self.invalidate_ui_region(cx, UiRegion::Root); } + pub(super) fn cycle_git_file_view_mode(&mut self, cx: &mut Context) { + self.save_settings_async( + "cycle_git_file_view_mode", + "switching Git layout", + |service| service.cycle_git_file_view_mode(), + |app, settings, cx| { + app.apply_async_settings_summary(settings); + app.invalidate_git_panel(cx); + }, + cx, + ); + } + + pub(super) fn set_git_file_view_mode( + &mut self, + mode: String, + _window: &mut Window, + cx: &mut Context, + ) { + self.save_settings_async( + "set_git_file_view_mode", + "saving Git layout", + move |service| service.set_git_file_view_mode(&mode), + |app, settings, cx| { + app.apply_async_settings_summary(settings); + app.invalidate_git_panel(cx); + }, + cx, + ); + } + + pub(super) fn set_git_review_compare_mode( + &mut self, + mode: String, + cx: &mut Context, + ) { + self.save_settings_async( + "set_git_review_compare_mode", + "switching Git compare mode", + move |service| service.set_git_review_compare_mode(&mode), + |app, settings, cx| { + app.apply_async_settings_summary(settings); + app.reload_git_review_for_compare_mode_change(cx); + }, + cx, + ); + } + pub(super) fn set_ai_refresh( &mut self, seconds: String, diff --git a/apps/desktop/src/app/sidebars/git.rs b/apps/desktop/src/app/sidebars/git.rs index ac1eade0..4db07f0a 100644 --- a/apps/desktop/src/app/sidebars/git.rs +++ b/apps/desktop/src/app/sidebars/git.rs @@ -57,9 +57,7 @@ pub(in crate::app) struct GitSidebarLabels { restore_commit: String, review_changed_files: String, review_original: String, - review_new_file: String, review_final_file: String, - review_branch: String, review_select_file: String, review_empty: String, review_no_repository: String, @@ -69,8 +67,10 @@ pub(in crate::app) struct GitSidebarLabels { #[derive(Clone, PartialEq)] pub(in crate::app) struct GitFilesPanelSnapshot { language: String, + view_mode: String, branch: String, changed_files: Vec<(String, String, String)>, + flat_changed_files: Vec<(String, String, String)>, expanded_sections: Vec, expanded_dirs: Vec, tree_children: Vec<(String, Vec<(String, String, String)>)>, @@ -98,26 +98,27 @@ impl Render for GitFilesPanelView { let app_entity = self.app_entity.clone(); app_entity.update(cx, |app, cx| { let labels = Rc::new(GitSidebarLabels::load(&app.state.settings.language)); - let staged = app - .state - .git - .changed_files + let flatten = app.state.settings.git_file_view_mode.as_str() == "flatten"; + // Flatten mode must show every individual file with no folder + // grouping, so it uses the uncollapsed list — `changed_files` + // collapses deep directories into folder markers for lazy tree + // expansion, which would otherwise leak "folder" rows into flatten. + let source_files = if flatten { + &app.state.git.flat_changed_files + } else { + &app.state.git.changed_files + }; + let staged = source_files .iter() .filter(|file| is_git_staged_file(file)) .cloned() .collect::>(); - let changed = app - .state - .git - .changed_files + let changed = source_files .iter() .filter(|file| is_git_worktree_file(file)) .cloned() .collect::>(); - let untracked = app - .state - .git - .changed_files + let untracked = source_files .iter() .filter(|file| is_git_untracked_file(file)) .cloned() @@ -134,6 +135,7 @@ impl Render for GitFilesPanelView { &app.selected_git_files, labels, app.git_files_scroll_handle.clone(), + flatten, cx, ) .into_any_element() @@ -244,6 +246,7 @@ impl CoduxApp { GitFilesPanelSnapshot { language: self.state.settings.language.clone(), + view_mode: self.state.settings.git_file_view_mode.clone(), branch: self.state.git.branch.clone(), changed_files: self .state @@ -252,6 +255,13 @@ impl CoduxApp { .iter() .map(git_file_status_tuple) .collect(), + flat_changed_files: self + .state + .git + .flat_changed_files + .iter() + .map(git_file_status_tuple) + .collect(), expanded_sections, expanded_dirs, tree_children, @@ -366,9 +376,7 @@ impl GitSidebarLabels { restore_commit: tr("git.history.restore_local", "Restore to This Commit"), review_changed_files: tr("worktree.review.changed_files", "Changed Files"), review_original: tr("worktree.review.column.original", "Original"), - review_new_file: tr("worktree.review.column.new", "New File"), review_final_file: tr("worktree.review.column.final", "Final File"), - review_branch: tr("worktree.review.column.branch", "Branch"), review_select_file: tr( "worktree.review.select_file", "Select a changed file to compare.", @@ -532,7 +540,28 @@ fn git_panel_header( .flex() .items_center() .when(git.is_repository, |this| { + let flatten = cx + .entity() + .read(cx) + .state + .settings + .git_file_view_mode + .as_str() + == "flatten"; + let view_icon = if flatten { + HeroIconName::ListBullet + } else { + HeroIconName::RectangleGroup + }; this.child(assistant_header_icon_button( + "git-sidebar-view-mode", + view_icon, + cx, + |app, _event, _window, cx| { + app.cycle_git_file_view_mode(cx); + }, + )) + .child(assistant_header_icon_button( "git-sidebar-ai", HeroIconName::Sparkles, cx, @@ -1483,6 +1512,7 @@ fn git_files_panel( selected_files: &HashSet, labels: Rc, scroll_handle: VirtualListScrollHandle, + flatten: bool, cx: &mut Context, ) -> impl IntoElement { let rows = Rc::new(git_status_virtual_rows( @@ -1495,6 +1525,7 @@ fn git_files_panel( selected_file, selected_files, &labels, + flatten, )); let item_sizes = Rc::new( rows.iter() @@ -1889,6 +1920,7 @@ enum GitStatusVirtualRow { active: bool, selected_files: HashSet, depth: usize, + show_path: bool, labels: Rc, }, Limit { @@ -1900,13 +1932,35 @@ enum GitStatusVirtualRow { const GIT_STATUS_GROUP_TOP_PADDING: f32 = 4.0; const GIT_STATUS_GROUP_BOTTOM_PADDING: f32 = 8.0; +fn git_file_status_parent_dir(file: &GitFileStatus, show_path: bool) -> Option { + if !show_path || file.path.ends_with('/') { + return None; + } + file.path + .trim_end_matches('/') + .rsplit_once('/') + .map(|(dir, _)| dir.to_string()) +} + +const GIT_STATUS_FILE_ROW_HEIGHT: f32 = 24.0; +const GIT_STATUS_FILE_ROW_WITH_PATH_HEIGHT: f32 = 36.0; + impl GitStatusVirtualRow { fn height(&self) -> Pixels { match self { Self::GroupHeader { .. } => px(40.0), Self::Spacer { height } => px(*height), Self::Empty { .. } => px(42.0), - Self::Dir { .. } | Self::File { .. } => px(24.0), + Self::Dir { .. } => px(GIT_STATUS_FILE_ROW_HEIGHT), + Self::File { + file, show_path, .. + } => { + if git_file_status_parent_dir(file, *show_path).is_some() { + px(GIT_STATUS_FILE_ROW_WITH_PATH_HEIGHT) + } else { + px(GIT_STATUS_FILE_ROW_HEIGHT) + } + } Self::Limit { .. } => px(32.0), } } @@ -1945,6 +1999,7 @@ impl GitStatusVirtualRow { active, selected_files, depth, + show_path, labels, } => { let selected_path = active.then(|| file.path.clone()); @@ -1953,6 +2008,7 @@ impl GitStatusVirtualRow { selected_path.as_deref(), &selected_files, depth, + show_path, labels, cx, ) @@ -1980,6 +2036,7 @@ fn git_status_virtual_rows( selected_file: Option<&str>, selected_files: &HashSet, labels: &GitSidebarLabels, + flatten: bool, ) -> Vec { let mut rows = Vec::new(); let file_menu_labels = Rc::new(GitFileMenuLabels::from(labels)); @@ -1996,6 +2053,7 @@ fn git_status_virtual_rows( labels.tree_limit.clone(), file_menu_labels.clone(), rows.is_empty(), + flatten, &mut rows, ); append_git_status_group_virtual_rows( @@ -2011,6 +2069,7 @@ fn git_status_virtual_rows( labels.tree_limit.clone(), file_menu_labels.clone(), rows.is_empty(), + flatten, &mut rows, ); append_git_status_group_virtual_rows( @@ -2026,6 +2085,7 @@ fn git_status_virtual_rows( labels.tree_limit.clone(), file_menu_labels, rows.is_empty(), + flatten, &mut rows, ); rows @@ -2044,6 +2104,7 @@ fn append_git_status_group_virtual_rows( tree_limit: String, file_menu_labels: Rc, first: bool, + flatten: bool, rows: &mut Vec, ) { let expanded = expanded_sections.contains(id); @@ -2069,18 +2130,37 @@ fn append_git_status_group_virtual_rows( return; } let start_len = rows.len(); - append_git_status_virtual_directory_rows( - id, - "", - files, - 0, - expanded_dirs, - tree_children, - selected_file, - selected_files, - file_menu_labels, - rows, - ); + if flatten { + for file in files.iter() { + if rows.len() >= MAX_GIT_STATUS_TREE_ROWS { + break; + } + let active = selected_file + .map(|path| path == file.path.as_str()) + .unwrap_or(false); + rows.push(GitStatusVirtualRow::File { + file: file.clone(), + active, + selected_files: selected_files.clone(), + depth: 0, + show_path: true, + labels: file_menu_labels.clone(), + }); + } + } else { + append_git_status_virtual_directory_rows( + id, + "", + files, + 0, + expanded_dirs, + tree_children, + selected_file, + selected_files, + file_menu_labels, + rows, + ); + } let appended = rows.len().saturating_sub(start_len); if appended >= MAX_GIT_STATUS_TREE_ROWS { rows.push(GitStatusVirtualRow::Limit { @@ -2154,6 +2234,7 @@ fn append_git_status_virtual_directory_rows( active, selected_files: selected_files.clone(), depth, + show_path: false, labels: file_menu_labels.clone(), }); } @@ -2583,6 +2664,7 @@ fn git_status_file_row( selected_file: Option<&str>, selected_files: &HashSet, depth: usize, + show_path: bool, labels: Rc, cx: &mut Context, ) -> impl IntoElement { @@ -2595,15 +2677,20 @@ fn git_status_file_row( || selected_files.contains(&file.path); let file_path = file.path.clone(); let menu_file_path = file.path.clone(); - let file_name = file - .path - .trim_end_matches('/') + let is_dir_status = file.path.ends_with('/'); + let trimmed_path = file.path.trim_end_matches('/'); + let file_name = trimmed_path .rsplit('/') .next() .filter(|name| !name.trim().is_empty()) .unwrap_or(file.path.as_str()) .to_string(); - let is_dir_status = file.path.ends_with('/'); + let parent_dir = git_file_status_parent_dir(&file, show_path); + let row_height = if parent_dir.is_some() { + GIT_STATUS_FILE_ROW_WITH_PATH_HEIGHT + } else { + GIT_STATUS_FILE_ROW_HEIGHT + }; let app_entity = cx.entity(); div() @@ -2613,7 +2700,7 @@ fn git_status_file_row( ))) .w_full() .min_w_0() - .h(px(24.0)) + .h(px(row_height)) .pl(px(46.0 + depth as f32 * 18.0)) .pr_3() .flex() @@ -2653,11 +2740,30 @@ fn git_status_file_row( .child( div() .ml(px(8.0)) + .flex() + .flex_col() + .justify_center() .min_w_0() - .text_size(rems(0.875)) - .line_height(rems(1.125)) - .truncate() - .child(file_name), + .gap(px(1.0)) + .child( + div() + .min_w_0() + .text_size(rems(0.875)) + .line_height(rems(1.125)) + .truncate() + .child(file_name), + ) + .when_some(parent_dir, |this, parent_dir| { + this.child( + div() + .min_w_0() + .text_size(rems(0.6875)) + .line_height(rems(1.0)) + .text_color(color(theme::TEXT_DIM)) + .truncate() + .child(parent_dir), + ) + }), ), ) .child( @@ -3416,7 +3522,6 @@ pub(in crate::app) fn git_review_workspace( let message = git_review_error_message(&error, labels.as_ref()); return git_review_empty_workspace(message).into_any_element(); } - let task_branch_mode = review.mode == "taskBranch"; let original_title = labels.review_original.clone(); let body = div() .flex() @@ -3476,13 +3581,6 @@ pub(in crate::app) fn git_review_workspace( VirtualListScrollHandle::from(code_scroll_handle.clone()), cx, )) - .child(git_review_content_panel( - "git-review-new-code", - labels.review_new_file.as_str(), - derived_rows.new_file.clone(), - VirtualListScrollHandle::from(code_scroll_handle.clone()), - cx, - )) .child(git_review_content_panel( "git-review-final-code", labels.review_final_file.as_str(), @@ -3490,15 +3588,6 @@ pub(in crate::app) fn git_review_workspace( VirtualListScrollHandle::from(code_scroll_handle.clone()), cx, )) - .when(task_branch_mode, |this| { - this.child(git_review_content_panel( - "git-review-branch-code", - labels.review_branch.as_str(), - derived_rows.branch.clone().unwrap_or_default(), - VirtualListScrollHandle::from(code_scroll_handle.clone()), - cx, - )) - }) }); body.into_any_element() } @@ -3550,9 +3639,7 @@ enum GitReviewLineTone { #[derive(Clone, Default)] pub(in crate::app) struct GitReviewDerivedRows { original: Rc>, - new_file: Rc>, final_file: Rc>, - branch: Option>>, } #[derive(Clone, Default)] @@ -3686,23 +3773,17 @@ fn git_review_code_line(cell: GitReviewAlignedCell, content_width: Pixels) -> An pub(in crate::app) fn build_git_review_derived_rows( original_content: &str, - new_content: &str, final_content: &str, - branch_content: Option<&str>, deleted_lines: &[usize], added_lines: &[usize], ) -> GitReviewDerivedRows { let original_lines = split_review_lines(original_content); - let new_lines = split_review_lines(new_content); let final_lines = split_review_lines(final_content); - let branch_lines = branch_content.map(split_review_lines); let deleted = deleted_lines.iter().copied().collect::>(); let added = added_lines.iter().copied().collect::>(); let mut original_cells = Vec::new(); - let mut new_cells = Vec::new(); let mut final_cells = Vec::new(); - let mut branch_cells = branch_lines.as_ref().map(|_| Vec::new()); let mut old_line = 1usize; let mut new_line = 1usize; @@ -3731,31 +3812,15 @@ pub(in crate::app) fn build_git_review_derived_rows( old_number, Some(GitReviewLineTone::Deletion), )); - new_cells.push(review_cell( - &new_lines, - new_number, - Some(GitReviewLineTone::Addition), - )); final_cells.push(review_cell( &final_lines, new_number, Some(GitReviewLineTone::Addition), )); - if let (Some(lines), Some(cells)) = (&branch_lines, &mut branch_cells) { - cells.push(review_cell( - lines, - new_number, - Some(GitReviewLineTone::Addition), - )); - } } } else { original_cells.push(review_cell(&original_lines, Some(old_line), None)); - new_cells.push(review_cell(&new_lines, Some(new_line), None)); final_cells.push(review_cell(&final_lines, Some(new_line), None)); - if let (Some(lines), Some(cells)) = (&branch_lines, &mut branch_cells) { - cells.push(review_cell(lines, Some(new_line), None)); - } old_line += 1; new_line += 1; } @@ -3763,9 +3828,7 @@ pub(in crate::app) fn build_git_review_derived_rows( GitReviewDerivedRows { original: Rc::new(original_cells), - new_file: Rc::new(new_cells), final_file: Rc::new(final_cells), - branch: branch_cells.map(Rc::new), } } @@ -3796,6 +3859,7 @@ pub(in crate::app) fn git_review_file_list( expanded_dirs: &HashSet, refreshing: bool, labels: Rc, + flatten: bool, cx: &mut Context, ) -> impl IntoElement { let expanded_dirs = expanded_dirs.clone(); @@ -3856,20 +3920,52 @@ pub(in crate::app) fn git_review_file_list( .min_h_0() .overflow_y_scrollbar() .py_2() - .children(git_review_directory_rows( - &review.files, - "", - 0, - selected_path, - &expanded_dirs, - labels.clone(), - app_entity.clone(), - cx, - )) + .children(if flatten { + git_review_flat_rows( + &review.files, + selected_path, + labels.clone(), + app_entity.clone(), + cx, + ) + } else { + git_review_directory_rows( + &review.files, + "", + 0, + selected_path, + &expanded_dirs, + labels.clone(), + app_entity.clone(), + cx, + ) + }) .into_any_element() }) } +fn git_review_flat_rows( + files: &[GitReviewFile], + selected_path: Option<&str>, + labels: Rc, + app_entity: gpui::Entity, + cx: &mut Context, +) -> Vec { + let mut rows = Vec::new(); + for file in files { + if rows.len() >= MAX_GIT_REVIEW_TREE_ROWS { + rows.push(git_review_tree_limit_row(files.len(), &labels).into_any_element()); + break; + } + let selected = selected_path == Some(file.path.as_str()); + rows.push( + git_review_file_row(file.clone(), selected, 0, true, app_entity.clone(), cx) + .into_any_element(), + ); + } + rows +} + fn git_review_directory_rows( files: &[GitReviewFile], base_path: &str, @@ -3912,7 +4008,8 @@ fn git_review_directory_rows( } let selected = selected_path == Some(file.path.as_str()); rows.push( - git_review_file_row(file, selected, depth, app_entity.clone(), cx).into_any_element(), + git_review_file_row(file, selected, depth, false, app_entity.clone(), cx) + .into_any_element(), ); } rows @@ -3940,23 +4037,30 @@ fn git_review_file_row( file: GitReviewFile, selected: bool, depth: usize, + show_path: bool, app_entity: gpui::Entity, cx: &mut Context, ) -> impl IntoElement { let path = file.path.clone(); let badge = git_review_status_badge(&file.status); - let file_name = file - .path - .trim_end_matches('/') + let trimmed_path = file.path.trim_end_matches('/'); + let file_name = trimmed_path .rsplit('/') .next() .filter(|name| !name.trim().is_empty()) .unwrap_or(file.path.as_str()) .to_string(); + let parent_dir = show_path + .then(|| trimmed_path.rsplit_once('/').map(|(dir, _)| dir.to_string())) + .flatten(); Button::new(format!("review-file-{path}")) .ghost() .w_full() - .h(px(24.0)) + .h(if parent_dir.is_some() { + px(36.0) + } else { + px(24.0) + }) .px_2() .rounded_sm() .text_color(if selected { @@ -3995,12 +4099,32 @@ fn git_review_file_row( div() .flex_1() .ml(px(8.0)) + .flex() + .flex_col() + .justify_center() .min_w_0() .max_w_full() - .truncate() - .text_size(rems(0.875)) - .line_height(rems(1.125)) - .child(file_name), + .gap(px(1.0)) + .child( + div() + .min_w_0() + .max_w_full() + .truncate() + .text_size(rems(0.875)) + .line_height(rems(1.125)) + .child(file_name), + ) + .when_some(parent_dir, |this, parent_dir| { + this.child( + div() + .min_w_0() + .truncate() + .text_size(rems(0.6875)) + .line_height(rems(1.0)) + .text_color(color(theme::TEXT_DIM)) + .child(parent_dir), + ) + }), ), ) .child(git_review_stats_cells( diff --git a/apps/desktop/src/app/sidebars/mod.rs b/apps/desktop/src/app/sidebars/mod.rs index 6f754068..17f2c3f9 100644 --- a/apps/desktop/src/app/sidebars/mod.rs +++ b/apps/desktop/src/app/sidebars/mod.rs @@ -494,6 +494,12 @@ fn git_fingerprint(git: &GitSummary) -> u64 { .map(git_file_status_fingerprint) .collect::>(), ), + hash_sidebar_value( + &git.flat_changed_files + .iter() + .map(git_file_status_fingerprint) + .collect::>(), + ), hash_sidebar_value( &git.branches .iter() @@ -569,6 +575,7 @@ fn git_interaction_fingerprint(app: &CoduxApp) -> u64 { .and_then(|project| project.git_default_push_remote_name.clone()), app.git_clone_remote_url.clone(), app.state.settings.language.clone(), + app.state.settings.git_file_view_mode.clone(), app.git_remote_editor_open, )), hash_sidebar_value(&( diff --git a/apps/desktop/src/app/workspace_review.rs b/apps/desktop/src/app/workspace_review.rs index e4ca1f49..1910c517 100644 --- a/apps/desktop/src/app/workspace_review.rs +++ b/apps/desktop/src/app/workspace_review.rs @@ -11,6 +11,7 @@ pub(in crate::app) struct ReviewWorkspaceSnapshot { review: GitReviewSummary, expanded_dirs: HashSet, refreshing: bool, + flatten: bool, content: Option, derived_rows: Option, labels: Rc, @@ -78,6 +79,7 @@ impl CoduxApp { review: self.git_review.clone(), expanded_dirs: self.git_expanded_dirs.clone(), refreshing: self.git_review_refreshing, + flatten: self.state.settings.git_file_view_mode == "flatten", content, derived_rows, labels, @@ -124,6 +126,7 @@ impl CoduxApp { self.selected_git_file.clone(), expanded_dirs, self.git_review_refreshing, + self.state.settings.git_file_view_mode.clone(), )), super::workspace_views::workspace_view_hash(&self.git_review_content.as_ref().map( |content| { @@ -134,7 +137,6 @@ impl CoduxApp { content.deleted_lines.clone(), content.head_content.len(), content.base_content.as_ref().map(|value| value.len()), - content.index_content.as_ref().map(|value| value.len()), content.worktree_content.len(), ) }, @@ -149,6 +151,7 @@ pub(in crate::app) struct ReviewFileListSnapshot { selected_path: Option, expanded_dirs: HashSet, refreshing: bool, + flatten: bool, labels: Rc, fingerprint: u64, } @@ -185,12 +188,14 @@ impl ReviewWorkspaceSnapshot { selected_path: self.selected_path.clone(), expanded_dirs: self.expanded_dirs.clone(), refreshing: self.refreshing, + flatten: self.flatten, labels: self.labels.clone(), fingerprint: super::workspace_views::workspace_view_hash(&( self.review_file_fingerprint(), self.selected_path.clone(), expanded_dirs, self.refreshing, + self.flatten, )), } } @@ -352,6 +357,7 @@ pub(in crate::app) fn review_file_list( &snapshot.expanded_dirs, snapshot.refreshing, snapshot.labels, + snapshot.flatten, cx, ) .into_any_element() @@ -382,7 +388,6 @@ fn review_content_fingerprint(content: Option<&GitReviewContentSummary>) -> Opti content.deleted_lines.clone(), content.head_content.len(), content.base_content.as_ref().map(|value| value.len()), - content.index_content.as_ref().map(|value| value.len()), content.worktree_content.len(), )) }) diff --git a/crates/codux-git/src/diff.rs b/crates/codux-git/src/diff.rs index 53585a79..b68061c5 100644 --- a/crates/codux-git/src/diff.rs +++ b/crates/codux-git/src/diff.rs @@ -20,17 +20,19 @@ fn git2_diff_to_string( diff_to_string(&diff) } -fn git2_commit_diff_to_string( +/// Diffs `base` against the current index + working directory (i.e. what +/// would ship if pushed) rather than just committed history, so staged and +/// unstaged edits on top of `base` are included. +fn git2_commit_to_workdir_diff_to_string( repo: &GitRepository, base: &str, path: Option<&str>, context_lines: u32, ) -> Result { let base_tree = resolve_commit_tree(repo, base)?; - let head_tree = head_tree(repo)?; let mut options = git2_diff_options(path, context_lines); let diff = repo - .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut options)) + .diff_tree_to_workdir_with_index(Some(&base_tree), Some(&mut options)) .map_err(|error| error.message().to_string())?; diff_to_string(&diff) } @@ -265,17 +267,6 @@ fn git2_blob(repo: &GitRepository, reference: &str, path: &str) -> Result Result { - let index = repo.index().map_err(|error| error.message().to_string())?; - let Some(entry) = index.get_path(Path::new(path), 0) else { - return Err("File is not in the index.".to_string()); - }; - let blob = repo - .find_blob(entry.id) - .map_err(|error| error.message().to_string())?; - Ok(String::from_utf8_lossy(blob.content()).to_string()) -} - fn read_worktree_file(root: &Path, path: &str) -> Result { let root = root.canonicalize().map_err(|error| error.to_string())?; let full_path = root.join(path); diff --git a/crates/codux-git/src/repository.rs b/crates/codux-git/src/repository.rs index 4f23861b..a7fbbfa2 100644 --- a/crates/codux-git/src/repository.rs +++ b/crates/codux-git/src/repository.rs @@ -20,7 +20,8 @@ fn git_status_from_repo(repo: &GitRepository) -> GitSummary { let upstream = upstream_branch_name(repo); let (ahead, behind) = ahead_behind(repo).unwrap_or((0, 0)); let head_pushed = head_commit_pushed_from_repo(repo); - let (raw_changed_files, status_error) = match git2_status_files(repo) { + let (raw_changed_files, status_error) = + match git2_status_files_with_options(repo, true, None, MAX_GIT_STATUS_FILES) { Ok((staged, unstaged, untracked)) => { (flatten_unique_status_files(staged, unstaged, untracked), None) } @@ -65,6 +66,7 @@ fn git_status_from_repo(repo: &GitRepository) -> GitSummary { is_repository: true, error: status_error, changed_files, + flat_changed_files: raw_changed_files, branches, remote_branches, remotes, @@ -129,11 +131,41 @@ fn git_review_from_repo(repo: &GitRepository, base_branch: Option<&str>) -> GitR .map(str::to_string); if let Some(base) = base { - let files = git2_commit_review_files(repo, &base).unwrap_or_default(); + let mut files = git2_commit_review_files(repo, &base).unwrap_or_default(); + let mut seen_paths: HashSet = + files.iter().map(|file| file.path.clone()).collect(); + let changed_files = flatten_status_files(repo); + let stats = working_tree_review_stats_git2(repo); + for file in changed_files.iter().filter(|file| { + let index = file.index_status.trim(); + !index.is_empty() && index != "?" + }) { + push_review_file_from_status(&mut files, &mut seen_paths, file, "staged", &stats, root); + } + for file in changed_files + .iter() + .filter(|file| !is_untracked_status(file) && !file.worktree_status.trim().is_empty()) + { + push_review_file_from_status( + &mut files, + &mut seen_paths, + file, + "modified", + &stats, + root, + ); + } + for file in changed_files + .iter() + .filter(|file| is_untracked_status(file)) + { + push_review_file_from_status(&mut files, &mut seen_paths, file, "added", &stats, root); + } + files.sort_by(|left, right| left.path.cmp(&right.path)); let diff_stat = review_diff_stat(&files); return GitReviewSummary { mode: "taskBranch".to_string(), - title: "Worktree Review".to_string(), + title: "Branch Review".to_string(), base_branch: Some(base), diff_stat, files, @@ -181,7 +213,8 @@ fn git_review_from_repo(repo: &GitRepository, base_branch: Option<&str>) -> GitR } fn flatten_status_files(repo: &GitRepository) -> Vec { - let (staged, unstaged, untracked) = git2_status_files(repo).unwrap_or_default(); + let (staged, unstaged, untracked) = + git2_status_files_with_options(repo, true, None, MAX_GIT_STATUS_FILES).unwrap_or_default(); flatten_unique_status_files(staged, unstaged, untracked) } diff --git a/crates/codux-git/src/service/review.rs b/crates/codux-git/src/service/review.rs index df60be62..12c5311b 100644 --- a/crates/codux-git/src/service/review.rs +++ b/crates/codux-git/src/service/review.rs @@ -26,7 +26,7 @@ impl GitService { .map(str::trim) .filter(|value| !value.is_empty() && *value != "current branch"); if let Some(base) = base { - return git2_commit_diff_to_string(&repo, base, Some(&file_path), 3); + return git2_commit_to_workdir_diff_to_string(&repo, base, Some(&file_path), 3); } let staged = @@ -84,13 +84,13 @@ impl GitService { .filter(|value| !value.is_empty() && *value != "current branch"); let head_content = git2_blob_or_empty(&repo, "HEAD", &file_path); let base_content = base.map(|reference| git2_blob_or_empty(&repo, reference, &file_path)); - let index_content = git2_index_blob(&repo, &file_path).ok(); let worktree_content = read_worktree_file(repo_root(&repo), &file_path).unwrap_or_default(); - let is_untracked = base.is_none() && is_untracked_path_git2(&repo, &file_path); + let is_untracked = is_untracked_path_git2(&repo, &file_path); let diff = if is_untracked { String::new() } else if let Some(base) = base { - git2_commit_diff_to_string(&repo, base, Some(&file_path), 0).unwrap_or_default() + git2_commit_to_workdir_diff_to_string(&repo, base, Some(&file_path), 0) + .unwrap_or_default() } else { let unstaged = git2_diff_to_string(&repo, DiffTarget::Worktree, Some(&file_path), 0) .unwrap_or_default(); @@ -115,7 +115,6 @@ impl GitService { path: file_path, head_content, base_content, - index_content, worktree_content, added_lines, deleted_lines, diff --git a/crates/codux-git/src/tests.rs b/crates/codux-git/src/tests.rs index c53bc4bb..497bff89 100644 --- a/crates/codux-git/src/tests.rs +++ b/crates/codux-git/src/tests.rs @@ -247,16 +247,16 @@ mod tests { fs::write(repo.join("bulk/nested/a.txt"), "a\n").expect("a"); let review = GitService::review(repo.to_str().expect("repo"), None); - assert!( - review.files.iter().any(|file| file.path == "bulk/"), - "review should show the untracked directory" - ); assert!( review .files .iter() - .all(|file| !file.path.starts_with("bulk/nested/")), - "review should not recurse into untracked directories" + .any(|file| file.path == "bulk/nested/a.txt"), + "review should show individual untracked files" + ); + assert!( + review.files.iter().all(|file| file.path != "bulk/"), + "review should not show untracked directory markers" ); let diff = GitService::review_file_diff( @@ -283,7 +283,6 @@ mod tests { assert_eq!(content.path, "new.txt"); assert_eq!(content.head_content, ""); - assert_eq!(content.index_content, None); assert_eq!(content.worktree_content, "one\ntwo\n"); assert_eq!(content.deleted_lines, Vec::::new()); assert_eq!(content.added_lines, vec![1, 2]); diff --git a/crates/codux-git/src/types.rs b/crates/codux-git/src/types.rs index 0ed89468..3b7931cb 100644 --- a/crates/codux-git/src/types.rs +++ b/crates/codux-git/src/types.rs @@ -12,6 +12,10 @@ pub struct GitSummary { pub is_repository: bool, pub error: Option, pub changed_files: Vec, + /// Same files as `changed_files` but never collapsed into directory + /// markers — the source list for flatten-mode display (no folders). + #[serde(default)] + pub flat_changed_files: Vec, pub branches: Vec, pub remote_branches: Vec, pub remotes: Vec, @@ -94,7 +98,6 @@ pub struct GitReviewContentSummary { pub path: String, pub head_content: String, pub base_content: Option, - pub index_content: Option, pub worktree_content: String, pub added_lines: Vec, pub deleted_lines: Vec, diff --git a/crates/codux-git/src/wire.rs b/crates/codux-git/src/wire.rs index b5ad7e3f..36296197 100644 --- a/crates/codux-git/src/wire.rs +++ b/crates/codux-git/src/wire.rs @@ -48,6 +48,11 @@ pub fn wire_status_summary(summary: GitSummary) -> WireStatusSummary { .into_iter() .map(|value| serde_json::to_value(value).unwrap_or(Value::Null)) .collect(), + flat_changed_files: summary + .flat_changed_files + .into_iter() + .map(|value| serde_json::to_value(value).unwrap_or(Value::Null)) + .collect(), branches: wire_branches(&summary.branches), remote_branches: summary.remote_branches, remotes: summary diff --git a/crates/codux-runtime-core/src/git.rs b/crates/codux-runtime-core/src/git.rs index d69c8314..aa88678e 100644 --- a/crates/codux-runtime-core/src/git.rs +++ b/crates/codux-runtime-core/src/git.rs @@ -14,6 +14,7 @@ pub struct GitStatusSummary { pub is_repository: bool, pub error: Option, pub changed_files: Vec, + pub flat_changed_files: Vec, pub branches: Vec, pub remote_branches: Vec, pub remotes: Vec, @@ -46,6 +47,7 @@ pub fn git_status_payload( "isRepository": summary.is_repository, "error": summary.error, "changedFiles": summary.changed_files, + "flatChangedFiles": summary.flat_changed_files, "branches": summary.branches, "remoteBranches": summary.remote_branches, "remotes": summary.remotes,