fix(partition): disk sonundaki boş alan hiç gösterilmiyordu

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) <noreply@anthropic.com>
This commit is contained in:
Erkan IŞIK
2026-09-27 17:31:51 +03:00
parent 7ff5c4b783
commit ad8bc0c506
2 changed files with 276 additions and 83 deletions
+15
View File
@@ -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).
+261 -83
View File
@@ -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<usize> },
}
/// 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<TableRow<'a>> {
let mut visible_rows: Vec<TableRow> = 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<usize> = 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<usize> },
}
let mut visible_rows: Vec<TableRow> = 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<String> = {
@@ -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<usize> = 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<TableRow> = 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<u64> {
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::*;