From ad8bc0c50649bb8755e41d99aa0668a98abe6d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erkan=20I=C5=9EIK?= Date: Sun, 27 Sep 2026 17:31:51 +0300 Subject: [PATCH] =?UTF-8?q?fix(partition):=20disk=20sonundaki=20bo=C5=9F?= =?UTF-8?q?=20alan=20hi=C3=A7=20g=C3=B6sterilmiyordu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kullanıcı raporu: "manuel partitionda boş disk alanı görünmüyor sda nın sda1 var 500mb sda toplamda 20gb 15gb görünmüyor." ## Sebep Boş alan satırı yalnızca İKİ durumda basılıyordu: 1. Silinmek işaretli bölüm varsa (ilk işaretli tanımın konumunda) 2. `all(|p| p.disk != active_disk)` — yani disk HİÇ tanım içermiyorsa Kullanıcının durumu ikisinin de dışında: sda'da sda1 (500 MB) var, silinmiş bölüm YOK, gerisi 15 GB boş. `fs_first` hiç dolmadığı için 1. dal çalışmadı; disk tanım içerdiği için 2. dal da çalışmadı. Sonuç: boş alan satırı hiç oluşmadı. Diskte kaç tanım olduğu ile kaç tanımın SİLİNM İŞARETLİ olduğu karıştırılmıştı. ## Düzeltme `build_table_rows(parts, active_disk, disk_mb)` olarak saf ve test edilebilir bir fonksiyona çıkarıldı (gömülüydü, `TableRow` enum'u da modül seviyesine alındı). Sondaki koşul artık yalnızca `free_total_mb > 0 && !free_row_shown`. `first_idx: None` "seçilemez" DEĞİL demektir; sadece temsil ettiği silinmiş bir tanım yoktur. Tıklanabilir: `global_idx == None` dalı `selected_free` tutuyor ve "Yeni Oluştur" butonunu gösteriyor. ## Test sırasında çıkan İKİNCİ hata `free_row_shown` bayrağını ilk yazdığımda silinmiş bölümler diskte ORTADA kaldığında iki boş alan satırı basılıyordu: biri döngüde (birleşik satır), biri de sonda. Aynı alan iki kez görünür ve toplam diskten büyük izlenimi doğardı. Test (`left: [10000, 10000]`) bunu yakaladı; bayrak eklendi. `cargo check` ayrıca `free_row_shown`'ın sondaki atamasının hiç okunmadığını bildirdi (iki dal birbirini dışlıyor) — o satır silindi. ## Testler (105 → 113) Yeni `free_space_tests` modülü: - sda1 + 15 GB boş, silinen yok → boş alan görünür (kullanıcı senaryosu) - tanım var/olmak arasındaki fark etkilemez - dolu diskte satır çizilmez - silinenler TEK satırda birleşir ve indeksi korur - boş alan toplamı disk kapasitesini aşmaz - silinen bölümün alanı boşa katılır - yalnızca aktif diskin satırları üretilir - disk seçili değilse satır yok Kapasite çubuğu (`manual_dual_bars`) zaten boşluğu doğru gösteriyordu; eksik olan tek yer tablo satırıydı. cargo check --all-targets : 0 hata, 0 uyarı cargo test : 113/113 Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 15 ++ src/steps/partition.rs | 344 +++++++++++++++++++++++++++++++---------- 2 files changed, 276 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b19736..677db20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,6 +226,21 @@ sda1 ile sdb1 çakışmaz. → kaydet. Artık "hangi disk" sorusunun **tek cevabı** var ve bayat değer sınıfı hata kalmıyor. `edit_disk_tests` bunu sabitler. - Tablo `TableRow::Part | TableRow::FreeSpace` enum'u ile kurulur. + Satır kurma mantığı **`build_table_rows(parts, active_disk, disk_mb)`** + saf fonksiyonunda (gömülüydü, iki kez yanlış hesaplandı): + - **Boş alan satırı disk başına TEK'tir.** Silinmek işaretliler ilk + işaretli tanımın konumunda birleşir (GParted tarzı). Satır ortada + basıldıysa sona ikinci bir tane **EKLENMEZ** (`free_row_shown` + bayrağı) — eklenirse aynı alan iki kez görünür ve toplam diskten + büyük izlenimi doğar. + - Disk sonundaki boş alan, diskte tanım **olup olmadığına bakılmaksızın** + basılır. REGRESYON: koşul `all(|p| p.disk != active_disk)` idi, + yani yalnızca tamamen boş disklerde görünüyordu. `sda1 (500 MB) + + 15 GB boş` → boş alan hiç çizilmiyordu. + - `FreeSpace.first_idx`: silinmiş tanımı temsil ediyorsa `Some`, + temsil ettiği tanım yoksa `None`. **`None` seçilemez demek + DEĞİLDİR** — tıklanınca `global_idx == None` dalı `selected_free` + tutar ve "Yeni Oluştur" butonu çıkar. - Seçili disk hiç tanım içermiyorsa boş-alan satırı basılır (`first_idx: None` → **seçilemez**; aksi hâlde `selected_idx = 0` ile başka diskin ilk bölümü seçilirdi). diff --git a/src/steps/partition.rs b/src/steps/partition.rs index 0c991af..e86d258 100644 --- a/src/steps/partition.rs +++ b/src/steps/partition.rs @@ -534,6 +534,114 @@ fn suggest_mountpoint(parts: &[CustomPartition], is_uefi: bool) -> &'static str } } +/// Bölümleme tablosunda görünen bir satır. +/// +/// Görünen her bölüm satırı `GlobalState.custom_partitions` içindeki +/// GERÇEK indeksine eşlenir; seçim/silme/düzenlemede bu global indeks +/// kullanılır. +enum TableRow<'a> { + Part(usize, &'a CustomPartition), + /// `first_idx`: silinmek işaretli tanımların birleştirildiği + /// satırda, işaretli ilk tanımın indeksi. `Some` ise satır o + /// tanımı temsil eder. + /// + /// `None`: temsil ettiği bir silinmiş tanım YOKTUR — disk tamamen + /// boştur ya da disk sonunda boşluk vardır. Satır yine de + /// **tıklanabilirdir**: `global_idx == None` dalı `selected_free` + /// tutar ve "Yeni Oluştur" butonunu gösterir. `None` yalnızca + /// "hangi tanımı silmeliyim" sorusunun cevabı olmadığını söyler. + FreeSpace { disk: String, total_mb: u64, first_idx: Option }, +} + +/// Seçili diskin tablo satırlarını kurar. +/// +/// Silinmek işaretli bölümler ayrı satır çizmez; İLK işaretlenenin +/// konumunda TEK bir "Boş Alan" satırında birleşirler (GParted tarzı). +/// +/// ⚠️ Boş alan, **diskte kaç tanım olduğuna değil kaç tanımın silinmek +/// işaretli olduğuna** bağlıdır. Geçmişte ikisi karıştırıldı ve disk +/// sonundaki boşluk hiç gösterilmedi: +/// `sda: sda1 (500 MB) + 15 GB boş, silinen yok` → boş alan satırı +/// YOK. Kullanıcı 15 GB'i hiç göremiyordu. +fn build_table_rows<'a>( + parts: &'a [CustomPartition], + active_disk: &str, + disk_mb: u64, +) -> Vec> { + let mut visible_rows: Vec = Vec::new(); + if active_disk.is_empty() { + return visible_rows; + } + + // Boş alan = disk boyutu − (korunan fiziksel + YENİ tanımlar). + // Silinenler zaten boşalır; yeni tanımlar da bu boşluktan + // yerleşeceği için gösterimden düşülürler. Toplam baştan + // hesaplanır ki satır nerede çizilirse çizilsin değer hep + // nihai kalanı göstersin. Boş alan 0'a düşerse satır çizilmez. + let used_total: u64 = parts.iter() + .filter(|p| p.disk == active_disk && !p.delete) + .map(|p| p.size_mb) + .sum(); + let free_total_mb = disk_mb.saturating_sub(used_total); + + let mut fs_first: Option = None; + // Boş alan satırı disk başına TEK'tir (GParted tarzı: silinenler + // ilk işaretli tanımın konumunda birleşir). Döngü içinde basıldıysa + // sona ikinci bir satır EKLENMEZ — yoksa aynı boş alan iki kez + // görünür ve toplamı diskten büyük görünür. + let mut free_row_shown = false; + for (gi, part) in parts.iter().enumerate() { + if part.disk != active_disk { continue; } + if part.delete { + if fs_first.is_none() { fs_first = Some(gi); } + continue; // silinen tanım kendi satırını çizmez + } + // İşaret bloğunun bittiği yerde boş-alan satırını bas + if let Some(first) = fs_first.take() { + if free_total_mb > 0 { + visible_rows.push(TableRow::FreeSpace { + disk: active_disk.to_string(), + total_mb: free_total_mb, + first_idx: Some(first), + }); + free_row_shown = true; + } + } + visible_rows.push(TableRow::Part(gi, part)); + } + // Grup sonu: işaretlenenler en sonadaysa burada bas + if let Some(first) = fs_first.take() { + // Son ifade olduğu için `free_row_shown` burada güncellenmez; + // zaten sonrasında okunacak bir dal yok. + if free_total_mb > 0 { + visible_rows.push(TableRow::FreeSpace { + disk: active_disk.to_string(), + total_mb: free_total_mb, + first_idx: Some(first), + }); + } + } else if free_total_mb > 0 && !free_row_shown { + // Disk SONUNDAKİ boş alan — diskte tanım olsun ya da olmasın. + // + // REGRESYON: koşul `all(|p| p.disk != active_disk)` idi, yani + // yalnızca "disk hiç tanım içermiyor" durumunu kapsıyordu. + // Diskte tanım var ama hiçbiri silinmek işaretli değilse + // (`sda1` + 15 GB boş) satır hiç basılmıyordu. + // + // `first_idx: None` burada "seçilemez" DEĞİL demektir; yalnızca + // temsil ettiği silinmiş bir tanım yoktur. Tıklanabilirdir + // (`global_idx == None` dalı `selected_free` tutar ve + // "Yeni Oluştur" butonunu gösterir). + visible_rows.push(TableRow::FreeSpace { + disk: active_disk.to_string(), + total_mb: free_total_mb, + first_idx: None, + }); + } + + visible_rows +} + /// Verilen diskin toplam boyutunu MB cinsinden döndürür. /// Disk listede yoksa 0 döner. fn disk_size_mb(state: &GlobalState, disk: &str) -> u64 { @@ -1068,19 +1176,10 @@ impl PartitionStep { let _border_stroke = egui::Stroke::new(1.0_f32, crate::ui::theme::c_border()); // ── Veri satırları (diske göre gruplu) ──────────── - // Görünen her bölüm satırını GlobalState'teki gerçek indeksine eşle; - // seçim/silme/düzenlemede bu global indeks kullanılır. - // Silinmek işaretli bölümler ayrı satırlar çizmez; İLK işaretlenenin - // konumunda TEK bir "Boş Alan" satırında birleşirler (GParted tarzı). - enum TableRow<'a> { - Part(usize, &'a CustomPartition), - /// `first_idx`: silinmek işaretli tanımların birleştirildiği - /// satırda, işaretli ilk tanımın indeksi (seçilebilir). - /// `None`: disk HİÇ tanım içermiyor (yeni/boş disk) — - /// seçilemez, çünkü indeks karşılığı olan bir satır yok. - FreeSpace { disk: String, total_mb: u64, first_idx: Option }, - } - let mut visible_rows: Vec = Vec::new(); + // Satır kurma mantığı `build_table_rows` içinde; saf olduğu için + // test edilebiliyor (boş alan satırının ne zaman çizildiği + // buradan görünmüyordu ve iki kez yanlış hesaplandı). + // // Disk şeridi için: `available_disks` + tanımı olan ama listede // olmayan diskler. Sıra `available_disks`'teki tarama sırasıdır. let seen_disk_names: Vec = { @@ -1092,76 +1191,23 @@ impl PartitionStep { } names }; - { - // Hangi disk GÖSTERİLECEK? - // - // Kullanıcı isteği: tablo TÜM diskleri değil, üstteki seçicide - // seçili OLAN diskin bölüm tablosunu gösterir. Tüm diskleri tek - // tabloda göstermek kafa karışıklığı yaratıyordu — özellikle - // "Sil" gibi işlemler hangi diski etkilediği belirsizleşiyordu. - // - // Diğer diskler GİZLİLENMİYOR: seçicinin altındaki disk şeridi - // her diskin bölüm sayısını ve kullanılan alanını gösterir ve - // tıklanınca o diski seçer. "sda'da 3 bölüm var" bilgisi - // kaybolmaz, ama dikkat tek diskte kalır. - let active_disk: &str = state.selected_disk.as_deref().unwrap_or(""); - - if !active_disk.is_empty() { - // Boş alan = disk boyutu − (korunan fiziksel + YENİ tanımlar). - // Silinenler zaten boşalır; yeni tanımlar da bu boşluktan - // yerleşeceği için gösterimden düşülürler. Toplam baştan - // hesaplanır ki satır nerede çizilirse çizilsin değer hep - // nihai kalanı göstersin. Boş alan 0'a düşerse satır çizilmez. - let used_total: u64 = state.custom_partitions.iter() - .filter(|p| p.disk == active_disk && !p.delete) - .map(|p| p.size_mb) - .sum(); - let free_total_mb = disk_size_mb(state, active_disk).saturating_sub(used_total); - - let mut fs_first: Option = None; - for (gi, part) in state.custom_partitions.iter().enumerate() { - if part.disk != active_disk { continue; } - if part.delete { - if fs_first.is_none() { fs_first = Some(gi); } - continue; // silinen tanım kendi satırını çizmez - } - // İşaret bloğunun bittiği yerde boş-alan satırını bas - if let Some(first) = fs_first.take() { - if free_total_mb > 0 { - visible_rows.push(TableRow::FreeSpace { - disk: active_disk.to_string(), - total_mb: free_total_mb, - first_idx: Some(first), - }); - } - } - visible_rows.push(TableRow::Part(gi, part)); - } - // Grup sonu: işaretlenenler en sonadaysa burada bas - if let Some(first) = fs_first.take() { - if free_total_mb > 0 { - visible_rows.push(TableRow::FreeSpace { - disk: active_disk.to_string(), - total_mb: free_total_mb, - first_idx: Some(first), - }); - } - } else if state.custom_partitions.iter().all(|p| p.disk != active_disk) { - // Seçili disk HİÇ tanım içermiyor (yeni/boş disk). - // Kullanıcıya "buraya bölüm ekleyebilirsin" bilgisi - // veren boş-alan satırı basılır. `first_idx: None` - // olduğu için SEÇİLEMEZ; aksi hâlde `selected_idx = 0` - // ile başka diskin ilk bölümü seçilirdi. - if free_total_mb > 0 { - visible_rows.push(TableRow::FreeSpace { - disk: active_disk.to_string(), - total_mb: free_total_mb, - first_idx: None, - }); - } - } - } - } + // Hangi disk GÖSTERİLECEK? + // + // Kullanıcı isteği: tablo TÜM diskleri değil, üstteki seçicide + // seçili OLAN diskin bölüm tablosunu gösterir. Tüm diskleri tek + // tabloda göstermek kafa karışıklığı yaratıyordu — özellikle + // "Sil" gibi işlemler hangi diski etkilediği belirsizleşiyordu. + // + // Diğer diskler GİZLİLENMİYOR: seçicinin altındaki disk şeridi + // her diskin bölüm sayısını ve kullanılan alanını gösterir ve + // tıklanınca o diski seçer. "sda'da 3 bölüm var" bilgisi + // kaybolmaz, ama dikkat tek diskte kalır. + let active_disk: &str = state.selected_disk.as_deref().unwrap_or(""); + let visible_rows: Vec = build_table_rows( + &state.custom_partitions, + active_disk, + disk_size_mb(state, active_disk), + ); let total_rows = visible_rows.len(); let mut last_disk_header = String::new(); @@ -3378,6 +3424,138 @@ mod multidisk_tests { } } +#[cfg(test)] +mod free_space_tests { + use super::build_table_rows; + use super::testutil::part; + use super::TableRow; + use crate::installer::FsType; + + fn free_mb_of<'a>(rows: &'a [TableRow<'a>]) -> Vec { + rows.iter() + .filter_map(|r| match r { + TableRow::FreeSpace { total_mb, .. } => Some(*total_mb), + _ => None, + }) + .collect() + } + + /// Kullanıcının durumu: sda'da sda1 (500 MB) var, gerisi 15 GB boş, + /// silinmek işaretli bölüm YOK. Boş alan satırı görünmeli. + #[test] + fn trailing_free_space_is_shown_without_any_deletions() { + let parts = vec![part("/dev/sda1", 500, FsType::Ntfs, "")]; + let rows = build_table_rows(&parts, "/dev/sda", 20_480); + + assert_eq!( + free_mb_of(&rows), + vec![20_480 - 500], + "sda1 + 15 GB boş → boş alan satırı görünmeli" + ); + assert_eq!(rows.len(), 2, "bölüm satırı + boş alan satırı olmalı"); + } + + /// REGRESYON: koşul `all(|p| p.disk != active_disk)` idi, yani + /// boş alan yalnızca disk HİÇ tanım içermiyorken basılıyordu. + /// Diskte sda1 varken 15 GB boş alan hiç gösterilmiyordu. + #[test] + fn free_space_shown_whether_or_not_the_disk_has_definitions() { + // Hiç tanım yok (tamamen boş disk) + let empty = build_table_rows(&[], "/dev/sdc", 100_000); + assert_eq!(free_mb_of(&empty), vec![100_000], "boş diskte tüm alan boş görünmeli"); + + // Tanım var, silinen yok + let defs = vec![part("/dev/sda1", 500, FsType::Ntfs, "")]; + let some = build_table_rows(&defs, "/dev/sda", 20_480); + assert_eq!(free_mb_of(&some), vec![19_980], "tanımlı diskte de kalan görünmeli"); + } + + /// Boş alan 0'a düşerse satır çizilmemeli — disk tamamen dolu. + #[test] + fn no_free_row_when_the_disk_is_full() { + let parts = vec![part("/dev/sda1", 20_480, FsType::Ext4, "/")]; + let rows = build_table_rows(&parts, "/dev/sda", 20_480); + assert!(free_mb_of(&rows).is_empty(), "dolu diskte boş alan satırı olmamalı"); + } + + /// Silinmek işaretli bölümler TEK boş alan satırında birleşir + /// (GParted tarzı) ve satır işaretli ilk tanımı temsil eder. + /// + /// Disk başına tek satır kuralı, satır ortada basıldığında da + /// geçerlidir: sonda İKİNCİ bir satır eklenmez. Eklenirse aynı + /// boş alan iki kez görünür ve toplam diskten büyük izlenimi + /// doğar. + #[test] + fn deleted_parts_merge_into_one_free_row_that_keeps_its_index() { + let mut a = part("/dev/sda1", 4_000, FsType::Ext4, "/home"); + a.delete = true; + let mut b = part("/dev/sda2", 3_000, FsType::Ext4, "/var"); + b.delete = true; + let parts = vec![a, b, part("/dev/sda3", 10_000, FsType::Ext4, "/")]; + let rows = build_table_rows(&parts, "/dev/sda", 20_000); + + assert_eq!(free_mb_of(&rows), vec![10_000], "disk başına tek boş alan satırı olmalı"); + let represented = rows.iter().find_map(|r| match r { + TableRow::FreeSpace { first_idx, .. } => *first_idx, + _ => None, + }); + assert_eq!(represented, Some(0), "satır işaretli ilk tanımı temsil etmeli"); + } + + /// Boş alan satırı toplamı disk kapasitesini AŞMAMALI — satır + /// sayısı ne olursa olsun. + #[test] + fn free_space_rows_never_exceed_the_disk() { + let mut a = part("/dev/sda1", 4_000, FsType::Ext4, "/home"); + a.delete = true; + let mut b = part("/dev/sda2", 3_000, FsType::Ext4, "/var"); + b.delete = true; + let parts = vec![a, b, part("/dev/sda3", 10_000, FsType::Ext4, "/")]; + let rows = build_table_rows(&parts, "/dev/sda", 20_000); + + let free_total: u64 = free_mb_of(&rows).iter().sum(); + assert_eq!(free_total, 10_000, "boş alan toplamı 20 GB'yi aşmamalı"); + } + + /// Silinen tanımın boşluğu hesaba katılır: sda1 silinince 20480'e + /// 500 MB eklenmiş gibi tüm alan boş görünür. + #[test] + fn deleted_parts_free_up_their_space() { + let mut a = part("/dev/sda1", 500, FsType::Ntfs, ""); + a.delete = true; + let defs = vec![a]; + let rows = build_table_rows(&defs, "/dev/sda", 20_480); + assert_eq!(free_mb_of(&rows), vec![20_480], "silinen bölümün alanı boşa katılmalı"); + } + + /// Tablo tek diski gösterir — başka diskin tanımları satır üretmez. + #[test] + fn only_the_active_disk_produces_rows() { + let parts = vec![ + part("/dev/sda1", 500, FsType::Ntfs, ""), + part("/dev/sdb3", 400_000, FsType::Ext4, "/"), + ]; + let rows = build_table_rows(&parts, "/dev/sda", 20_480); + + assert_eq!(rows.len(), 2, "sda'nın 1 bölümü + boş alan olmalı"); + let devices: Vec<&str> = rows + .iter() + .filter_map(|r| match r { + TableRow::Part(_, p) => Some(p.device.as_str()), + _ => None, + }) + .collect(); + assert_eq!(devices, vec!["/dev/sda1"], "sdb'nin bölümleri görünmemeli"); + } + + /// Disk seçili değilse satır üretilmez (tüm diskler birden gösterilmez). + #[test] + fn no_disk_selected_means_no_rows() { + let parts = vec![part("/dev/sda1", 500, FsType::Ntfs, "")]; + assert!(build_table_rows(&parts, "", 20_480).is_empty()); + } +} + #[cfg(test)] mod table_scope_tests { use super::*;