From 5c148472c418d3fbac0f901ed9c16c2b50965e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erkan=20I=C5=9EIK?= Date: Sat, 22 Aug 2026 21:29:31 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=C3=A7oklu=20disk=20deste=C4=9Fi=20ve?= =?UTF-8?q?=20eski=20Yali=20tarz=C4=B1=20granular=20b=C3=B6l=C3=BCmleme=20?= =?UTF-8?q?i=C5=9Flemleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomPartition artık disk alanı taşıyor; bölümler diske göre gruplanıyor - Manuel modda TÜM disklerin mevcut bölümleri otomatik listeleniyor - Tablo her disk için kendi sütun başlıklarını gösteriyor - Silinmek işaretli bölümler tek 'Boş Alan' satırında birleşiyor (GParted tarzı); Boş Alan = disk − (korunanlar + yeni tanımlar), 0'a düşerse satır gizlenir - Sil/Geri Al: fiziksel bölümler silinmek İŞARETLENİR (OperationDestroyDevice karşılığı), kullanıcı tanımı olanlar listeden tamamen kalkar - Kurulum job'ları granular modele geçti: mktable ile tüm disk sıfırlanmıyor; rm → boş alana mkpart (sysfs geometrisi) → yalnızca istenenler mkfs. İşlemi olmayan disklere hiç dokunulmaz - Slot ataması parted davranışıyla birebir: silinen numara yeniden kullanılır - 'Kalan alan' sembolik kaldı; boyut kurulum anında boşluktan belirlenir (taşma imkânsız) + kapasite aşımı uyarısı/kilidi eklendi - Özet ekranı ve bölüm planı: disk başına Mevcut/Kurulum Sonrası çift bar, kapasite gösterimleri düzeltildi - Remixicon simge düzeltmesi (hard-drive), mp_* yeni çeviri anahtarları - Testler: slot yeniden kullanımı, kapasite aşımı, disk-bazlı hesaplar (22/22) --- locales/en.toml | 7 + locales/tr.toml | 7 + src/installer.rs | 79 ++++ src/jobs/mod.rs | 2 +- src/jobs/partition.rs | 260 +++++++++--- src/steps/partition.rs | 886 ++++++++++++++++++++++++++++++++--------- src/steps/summary.rs | 70 +++- 7 files changed, 1055 insertions(+), 256 deletions(-) diff --git a/locales/en.toml b/locales/en.toml index 00bda4a..1c83c42 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -141,6 +141,13 @@ display_manager_autologin_hint = "(first user)" display_manager_de_not_found = "(not detected)" display_manager_note = "Note: The display manager will be enabled by default on the installed system." mp_size_remaining = "Remaining Space" +mp_size_available = "Available" +mp_create_here = "Create Here" +mp_will_delete = "will be deleted" +mp_restore = "Restore" +mp_free_space = "Free Space" +mp_bar_now = "Current Layout" +mp_bar_after = "After Installation" # ── UI / General ────────────────────────────────────────── installer = "YALI Installation Tool" diff --git a/locales/tr.toml b/locales/tr.toml index 6c4ee30..c2d04ab 100644 --- a/locales/tr.toml +++ b/locales/tr.toml @@ -117,6 +117,13 @@ disk_too_small = "Disk çok küçük" summary_partition_plan = "Bölümleme" summary_custom_partitions = "Manuel Bölümler" mp_size_remaining = "Kalan Alan" +mp_size_available = "Kullanılabilir" +mp_create_here = "Buraya Oluştur" +mp_will_delete = "silinecek" +mp_restore = "Geri Al" +mp_free_space = "Boş Alan" +mp_bar_now = "Mevcut Durum" +mp_bar_after = "Kurulum Sonrası" # ── UI / Genel ──────────────────────────────────────────── installer = "PisiLinux Kurulum Aracı" diff --git a/src/installer.rs b/src/installer.rs index a0064b6..db7bb4e 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -167,11 +167,86 @@ pub struct LogicalVolume { pub mountpoint: String, } +/// Cihaz yolundan ait olduğu diskin yolunu çıkarır. +/// +/// - `/dev/sda1` → `/dev/sda` +/// - `/dev/nvme0n1p2` → `/dev/nvme0n1` +/// - `/dev/mmcblk0p1` → `/dev/mmcblk0` +#[allow(dead_code)] // genel yardımcı; testlerde ve gelecekteki işlerde kullanılır +pub fn device_to_disk(device: &str) -> String { + let base = device.trim_start_matches("/dev/"); + // NVMe/MMC/loop aygıtlarında bölüm ayracı "p" + rakamdır + let needs_p = base.contains("nvme") || base.contains("mmcblk") || base.contains("loop"); + if let Some(pos) = base.rfind('p') { + if needs_p && base[pos + 1..].chars().all(|c| c.is_ascii_digit()) && !base[pos + 1..].is_empty() { + return format!("/dev/{}", &base[..pos]); + } + } + // SATA/virtio: sondaki tüm rakamları at + let trimmed = base.trim_end_matches(|c: char| c.is_ascii_digit()); + format!("/dev/{}", trimmed) +} + +/// Cihaz adından bölüm numarasını çıkarır ("/dev/sda3" → 3, +/// "/dev/nvme0n1p2" → 2). Sonda rakam yoksa None döner. +pub fn part_number(device: &str) -> Option { + let base = device.trim_start_matches("/dev/"); + let digits: String = base.chars().rev().take_while(|c| c.is_ascii_digit()).collect(); + digits.chars().rev().collect::().parse().ok() +} + +/// Bölüm tanımlarına parted'ın gerçek slot davranışını izleyerek +/// aygıt numarası atar. +/// +/// Eski Yali modeliyle aynı mantık: fiziksel (silinmemiş) bölümler +/// kendi numaralarını korur; silinmek işaretlenenlerin slotları boşalır +/// ve yeni tanımlar en küçük boş slotu sırayla doldurur. Örn. sda2 +/// silinip yeni bölüm eklenirse yeni bölüm sda2 olur; sda3 yerinde kalır. +pub fn assign_partition_numbers(parts: &mut [CustomPartition]) { + use std::collections::BTreeSet; + // Disk bazlı kullanılan slot kümeleri + let mut used: std::collections::HashMap> = std::collections::HashMap::new(); + for p in parts.iter() { + if p.delete { continue; } // silinecekler slot açar + if !p.orig_device.is_empty() { // fiziksel: kendi numarası + if let Some(n) = part_number(&p.orig_device) { + used.entry(p.disk.clone()).or_default().insert(n); + } + } + } + for p in parts.iter_mut() { + if p.delete { + // Silinecek bölüm: kurulumda yok olacak; adı olduğu gibi kalır, + // yeni slot tüketmez. + continue; + } + match part_number(&p.orig_device) { + Some(n) => { p.device = part_path(&p.disk, n); } + None => { + let set = used.entry(p.disk.clone()).or_default(); + // en küçük boş slot + let mut n = 1u32; + while set.contains(&n) { n += 1; } + set.insert(n); + p.device = part_path(&p.disk, n); + } + } + } +} + /// Kullanıcının manuel olarak tanımladığı tek bir bölüm. #[derive(Clone, Debug)] pub struct CustomPartition { + /// Bölümün ait olduğu disk (ör. "/dev/sda"). + /// Çoklu disk kurulumunda bölümler diske göre gruplanır. + pub disk: String, /// Bölüm aygit adı; oluşturulacak (ör. "/dev/sda1") pub device: String, + /// Taramada bulunan fiziksel cihaz adı (örn. "/dev/sda3"). + /// Kullanıcı tarafından yeni eklenen tanımlarda boştur. + /// Silme işleminde satırın listede kalıp yeniden + /// tanımlanabilmesini sağlar. + pub orig_device: String, /// Boyut MB cinsinden; 0 = "kalan alan" pub size_mb: u64, /// Dosya sistemi @@ -186,6 +261,10 @@ pub struct CustomPartition { pub luks_name: String, /// false = biçimlendir, true = olduğu gibi bırak (mevcut bölümleri koru) pub skip_format: bool, + /// true = kurulum sırasında bu bölüm diskten SİLİNECEK + /// (eski Yali'deki OperationDestroyDevice karşılığı). + /// Satır listede görünür kalır; istenirse işaret geri alınır. + pub delete: bool, } /// Kurulum boyunca toplanan tüm veriler. diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index a24b521..19ce06b 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -633,7 +633,7 @@ pub fn build_custom_job_queue( let src = if cfg.source.is_empty() { detect_source_dir() } else { cfg.source }; let mut jobs: Vec> = vec![ - Box::new(CustomPartitionJob { disk: disk.clone(), custom_partitions: custom_partitions.clone() }), + Box::new(CustomPartitionJob { custom_partitions: custom_partitions.clone() }), Box::new(SetupLvmJob { volume_groups: volume_groups.clone(), logical_volumes: logical_volumes.clone() }), Box::new(MountCustomPartitionsJob { custom_partitions: custom_partitions.clone(), logical_volumes: logical_volumes.clone(), mount: mt.clone() }), Box::new(MountBindJob { mount: mt.clone() }), diff --git a/src/jobs/partition.rs b/src/jobs/partition.rs index 6be8884..e15b7e9 100644 --- a/src/jobs/partition.rs +++ b/src/jobs/partition.rs @@ -448,9 +448,75 @@ async fn read_partition_uuids( // MANUEL BÖLÜMLEME İÇİN DESTEKLEYİCİ JOB'LAR // ───────────────────────────────────────────── -/// Manuel bölümleme ayarlarını disk üzerinde uygulayan ve biçimlendiren Job. +/// Diskteki boş bölgeleri MB cinsinden hesaplar (sysfs tabanlı). +/// +/// `exclude_nums`: silinecek işaretli bölümler — boşluk olarak +/// davranılır (kurulum sırasında önce silindikleri için). +/// Dönen değer: (başlangıç_mb, bitiş_mb) aralıkları; disk sonunda +/// GPT yedek başlığı için 1MB pay bırakılır. +fn disk_free_regions_mb(disk: &str, exclude_nums: &[u32]) -> Result, String> { + let base = disk.trim_start_matches("/dev/"); + const SECTOR: u64 = 512; + const MB: u64 = 1024 * 1024; + + let disk_sectors: u64 = std::fs::read_to_string(format!("/sys/block/{}/size", base)) + .map_err(|e| format!("{} boyutu okunamadı: {}", disk, e))? + .trim().parse().unwrap_or(0); + if disk_sectors == 0 { + return Err(format!("{} disk boyutu okunamadı", disk)); + } + let total_mb = disk_sectors * SECTOR / MB; + let usable_total = total_mb.saturating_sub(1); // disk sonu güvenlik payı + + // Dolu aralıklar + let mut occupied: Vec<(u64, u64)> = Vec::new(); + if let Ok(entries) = std::fs::read_dir(format!("/sys/block/{}", base)) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with(base) || name == base { continue; } + let num = match crate::installer::part_number(&format!("/dev/{}", name)) { + Some(n) => n, + None => continue, + }; + if exclude_nums.contains(&num) { continue; } + let start_sectors: u64 = std::fs::read_to_string(entry.path().join("start")) + .ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); + let len_sectors: u64 = std::fs::read_to_string(entry.path().join("size")) + .ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); + if len_sectors == 0 { continue; } + let start_mb = (start_sectors * SECTOR + MB - 1) / MB; + let end_mb = ((start_sectors + len_sectors) * SECTOR) / MB; + if end_mb > start_mb { + occupied.push((start_mb, end_mb.min(usable_total))); + } + } + } + occupied.sort(); + + // Boşlukları çıkar (ilk 1MB boot alanı hariç) + const HEADROOM_MB: u64 = 1; + let mut regions: Vec<(u64, u64)> = Vec::new(); + let mut cursor = HEADROOM_MB.min(usable_total); + for (s, e) in &occupied { + if *s > cursor { regions.push((cursor, *s)); } + cursor = cursor.max(*e); + } + if cursor < usable_total { regions.push((cursor, usable_total)); } + regions.retain(|(s, e)| e > s); + Ok(regions) +} + +/// Manuel bölümleme ayarlarını granular (eski Yali Operation modeli) +/// yaklaşımla uygulayan Job. +/// +/// Eski Yali'nin DeviceTree/Operation mimarisiyle aynı felsefe: +/// - Dokunulmayan disklere ve bölümlere HİÇ dokunulmaz (veriler korunur) +/// - Silinmek işaretli bölümler `parted rm` ile kaldırılır +/// (OperationDestroyDevice karşılığı) +/// - Yeni tanımlar diskin BOŞ alanlarına yerleştirilir; tüm disk +/// sıfırlanmaz (OperationCreateDevice karşılığı) +/// - skip_format=false olanlar biçimlendirilir (OperationCreateFormat) pub struct CustomPartitionJob { - pub disk: String, pub custom_partitions: Vec, } @@ -460,68 +526,141 @@ impl Job for CustomPartitionJob { async fn run(&self, ui: &UiSender) -> Result<(), String> { let is_uefi = std::path::Path::new("/sys/firmware/efi").exists(); - - // 0. Diski hazırla: bağlamaları ayır, kernel tablosunu temizle - prepare_disk(&self.disk, ui).await; - - let is_gpt = is_uefi; // Genellikle UEFI ise GPT kullanılır // MBR'de "primary" zorunludur, GPT'de ise bu alan isim (label) alanıdır. - let part_type_or_name = if is_gpt { "" } else { "primary" }; + let part_type_or_name = if is_uefi { "" } else { "primary" }; - // 1. Bölüm tablosu oluştur - let table_label = if is_uefi { "gpt" } else { "msdos" }; - ui.log(format!("parted {} mktable {}", self.disk, table_label)); - run_parted(&["-s", &self.disk, "mktable", table_label]).await?; - - let mut next_mb: u64 = 1; // İlk MB boot sektörü - - // 2. Bölümleri parted ile oluştur - for (i, part) in self.custom_partitions.iter().enumerate() { - let part_num = (i + 1) as u32; - let end_str = if part.size_mb == 0 { - "100%".to_string() - } else { - format!("{}MB", next_mb + part.size_mb) - }; - let start_str = format!("{}MB", next_mb); - - let fs_label = match part.fstype { - FsType::Fat32 => "fat32", - FsType::Swap => "linux-swap", - FsType::Ext4 => "ext4", - FsType::Ext3 => "ext3", - FsType::Btrfs => "btrfs", - FsType::Xfs => "xfs", - FsType::Ntfs => "ntfs", - // LVM PV için parted "ext4" ile işaretle, pvcreate sonra - FsType::Lvm => "ext4", - // LVM LV doğrudan parted ile oluşturulmaz - FsType::LvmLv => continue, - }; - - ui.log(format!( - "Bölüm {} oluşturuluyor: {} - {} ({})", - part_num, start_str, end_str, fs_label - )); - run_parted(&[ - "-s", &self.disk, "mkpart", part_type_or_name, fs_label, - &start_str, &end_str - ]).await?; - - if part.mountpoint == "/boot/efi" || part.fstype == FsType::Fat32 { - run_parted(&["-s", &self.disk, "set", &part_num.to_string(), "esp", "on"]).await?; - } - - if part.size_mb > 0 { - next_mb += part.size_mb; + // 0. İşlem yapılan diskleri tanım sırasına göre topla + let mut disks: Vec = Vec::new(); + for part in &self.custom_partitions { + if !disks.contains(&part.disk) { + disks.push(part.disk.clone()); } } - // 2.5. Tüm mkpart bitti; kernel'e bildir ve aygıt düğümlerini bekle - sync_partition_table(&self.disk).await; + for disk in &disks { + let parts: Vec<&CustomPartition> = self.custom_partitions.iter() + .filter(|p| p.disk == *disk).collect(); - // 3. Biçimlendir (LUKS ile şifreleme desteklenir) + // Bu disk için planlanan işlemler + let destroys: Vec = parts.iter() + .filter(|p| p.delete && !p.orig_device.is_empty()) + .filter_map(|p| crate::installer::part_number(&p.orig_device)) + .collect(); + let mut creates: Vec<&CustomPartition> = parts.iter() + .filter(|p| !p.delete && p.orig_device.is_empty() && p.fstype != FsType::LvmLv) + .map(|p| *p) + .collect(); + // Sabit boyutlular ÖNCE yerleşir; "kalan alan" (size 0) + // tanımları EN SON, geriye kalan boşluğa yerleştirilir. + // (Stable sort → aynı grupta tanım sırası korunur.) + creates.sort_by_key(|p| p.size_mb == 0); + let has_format = parts.iter().any(|p| !p.delete && !p.skip_format); + + if destroys.is_empty() && creates.is_empty() && !has_format { + ui.log(format!("⏭️ {}: işlem yok, korunuyor", disk)); + continue; // eski Yali gibi dokunulmayan diske girilmez + } + + ui.log(format!("── Disk işleniyor: {} ──", disk)); + // Bağlamaları ayır, swap kapat + prepare_disk(disk, ui).await; + + // ── FAZ A: SİLMEK İŞARETLİ BÖLÜMLER (büyükten küçüğe numara) ── + let mut del_sorted = destroys.clone(); + del_sorted.sort_unstable_by(|a, b| b.cmp(a)); + for n in &del_sorted { + ui.log(format!("parted {} rm {} (bölüm siliniyor)", disk, n)); + run_parted(&["-s", disk, "rm", &n.to_string()]).await?; + } + if !del_sorted.is_empty() { + sync_partition_table(disk).await; + } + + // ── FAZ B: YENİ BÖLÜMLER (boş alana yerleştir) ────────────── + if !creates.is_empty() { + let mut regions = disk_free_regions_mb(disk, &destroys)?; + for part in &creates { + let fs_label = match part.fstype { + FsType::Fat32 => "fat32", + FsType::Swap => "linux-swap", + FsType::Ext4 => "ext4", + FsType::Ext3 => "ext3", + FsType::Btrfs => "btrfs", + FsType::Xfs => "xfs", + FsType::Ntfs => "ntfs", + // LVM PV için parted "ext4" ile işaretle, pvcreate sonra + FsType::Lvm => "ext4", + FsType::LvmLv => continue, + }; + + // Yerleşim: boyut belirtilmişse sığan İLK boş bölge, + // "kalan alan" ise en büyük bölgenin tamamı. + let need = part.size_mb; + let placement = if need == 0 { + let idx = regions.iter().enumerate() + .max_by_key(|(_, (s, e))| e.saturating_sub(*s)) + .map(|(i, _)| i); + match idx { + Some(i) => { + let r = regions.remove(i); + Some(r) + } + None => None, + } + } else { + let mut found = None; + for (i, (s, e)) in regions.iter().enumerate() { + if e.saturating_sub(*s) >= need { + found = Some((i, (*s, s + need))); + break; + } + } + match found { + Some((i, seg)) => { + let (_, e) = regions[i]; + let start = seg.0; + let new_start = start + need; + if e > new_start { + regions[i] = (new_start, e); + } else { + regions.remove(i); + } + Some(seg) + } + None => None, + } + }; + + let (start_mb, end_mb) = placement.ok_or_else(|| format!( + "{} üzerinde {} MB için yeterli boş alan yok", disk, need))?; + + let start_str = format!("{}MB", start_mb); + let end_str = format!("{}MB", end_mb); + let slot = crate::installer::part_number(&part.device).unwrap_or(0); + + ui.log(format!( + "{} üzerinde yeni bölüm oluşturuluyor: {} - {} ({})", + disk, start_str, end_str, fs_label + )); + run_parted(&[ + "-s", disk, "mkpart", part_type_or_name, fs_label, + &start_str, &end_str + ]).await?; + + if part.mountpoint == "/boot/efi" || part.fstype == FsType::Fat32 { + run_parted(&["-s", disk, "set", &slot.to_string(), "esp", "on"]).await?; + } + } + sync_partition_table(disk).await; + } + + // ── FAZ C: BİÇİMLENDİRME aşağıdaki ortak döngüde ──────────── + } + + // Biçimlendir (LUKS ile şifreleme desteklenir) — tüm diskler. + // Silinmek işaretli bölümlere hiç dokunulmaz. for part in &self.custom_partitions { + if part.delete { continue; } let path = &part.device; // LUKS şifreleme (kök veya isteğe bağlı bölümler) @@ -667,7 +806,8 @@ impl Job for MountCustomPartitionsJob { async fn run(&self, ui: &UiSender) -> Result<(), String> { // 1. Kök (/) bölümünü (fiziksel partition veya LVM LV) bul ve bağla - let root_device = if let Some(root_part) = self.custom_partitions.iter().find(|p| p.mountpoint == "/") { + let root_device = if let Some(root_part) = self.custom_partitions.iter() + .find(|p| p.mountpoint == "/" && !p.delete) { self.device_path(root_part) } else if let Some(root_lv) = self.logical_volumes.iter().find(|lv| lv.mountpoint == "/") { format!("/dev/{}/{}", root_lv.vg_name, root_lv.name) @@ -687,7 +827,7 @@ impl Job for MountCustomPartitionsJob { let mut mount_items = Vec::new(); for part in &self.custom_partitions { - if part.mountpoint != "/" && part.fstype != FsType::Swap && !part.mountpoint.is_empty() { + if part.mountpoint != "/" && part.fstype != FsType::Swap && !part.mountpoint.is_empty() && !part.delete { mount_items.push(MountItem { device: self.device_path(part), mountpoint: part.mountpoint.clone(), @@ -747,7 +887,7 @@ impl Job for FstabFromCustomPartitionsJob { ); for part in &self.custom_partitions { - if part.mountpoint.is_empty() { continue; } + if part.mountpoint.is_empty() || part.delete { continue; } let is_encrypted = part.encrypt && !part.luks_password.is_empty(); diff --git a/src/steps/partition.rs b/src/steps/partition.rs index 5d1db87..b2b1775 100644 --- a/src/steps/partition.rs +++ b/src/steps/partition.rs @@ -67,7 +67,9 @@ fn read_existing_partitions(disk_path: &str) -> Vec Vec Vec { } use crate::installer::{ - CustomPartition, DiskInfo, FsType, GlobalState, InstallerStep, + assign_partition_numbers, CustomPartition, DiskInfo, FsType, GlobalState, InstallerStep, PartitionPlan, PartitionTableType, VolumeGroup, }; @@ -222,8 +225,12 @@ const COMMON_MOUNTPOINTS: &[&str] = &["/", "/boot", "/boot/efi", "/home", "/var" struct ManualState { /// "Bölüm Ekle" diyaloğu açık mı? adding: bool, - /// "Bölüm Düzenle" diyaloğı açık mı? + /// "Bölüm Düzenle" diyaloğu açık mı? editing: bool, + /// Silinmiş (ataması kaldırılmış) bir bölümün ÜZERİNE yeni + /// tanım oluşturma modu. editing=true ile birlikte çalışır: + /// kaydet, mevcut satırın üzerine yazar. + create_over: bool, /// Düzenlenecek bölüm indeksi edit_idx: Option, /// Seçili satır indeksi @@ -276,7 +283,6 @@ pub struct PartitionStep { is_uefi: bool, manual: ManualState, show_luks_password: bool, - selected_disk_size_mb: Option, } impl Default for PartitionStep { @@ -286,7 +292,6 @@ impl Default for PartitionStep { is_uefi: std::path::Path::new("/sys/firmware/efi").exists(), manual: ManualState::default(), show_luks_password: false, - selected_disk_size_mb: None, } } } @@ -330,14 +335,17 @@ fn make_plan(disk: &DiskInfo, is_uefi: bool) -> Option { } /// Manuel listedeki bölümlerin geçerliliğini kontrol eder. +/// Silinmek işaretli bölümler kurulumda var olmayacağı için sayılmaz. fn validate_manual( parts: &[CustomPartition], lvs: &[crate::installer::LogicalVolume], is_uefi: bool, ) -> Option { - let has_root = parts.iter().any(|p| p.mountpoint == "/") + let live = |p: &&CustomPartition| !p.delete; + let has_root = parts.iter().filter(live).any(|p| p.mountpoint == "/") || lvs.iter().any(|lv| lv.mountpoint == "/"); - let has_efi = parts.iter().any(|p| p.mountpoint == "/boot/efi" || p.fstype == FsType::Fat32); + let has_efi = parts.iter().filter(live) + .any(|p| p.mountpoint == "/boot/efi" || p.fstype == FsType::Fat32); if !has_root { return Some(t!("mp_no_root").to_string()); @@ -348,6 +356,49 @@ fn validate_manual( None } +// ───────────────────────────────────────────── +// Çoklu disk yardımcıları +// ───────────────────────────────────────────── + +/// 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 { + state.available_disks.iter() + .find(|d| d.name == disk) + .map(|d| d.size_bytes / 1_048_576) + .unwrap_or(0) +} + +/// Bir diskteki, hariç tutulacak indeks dışındaki tanımlı bölümlerin +/// toplam boyutunu (MB) döndürür. size_mb == 0 ("kalan alan") ve +/// silinmek işaretli tanımlar toplama katkı vermez (boş alan sayılır). +fn used_mb_on_disk(parts: &[CustomPartition], disk: &str, exclude_idx: Option) -> u64 { + parts.iter().enumerate() + .filter(|(i, p)| &p.disk == disk && !p.delete && p.size_mb > 0 && Some(*i) != exclude_idx) + .map(|(_, p)| p.size_mb) + .sum() +} + +/// Sabit boyutlu tanımların disk boyutunu aşıp aşmadığını kontrol eder. +/// "Kalan alan" (size 0) tanımları esnektir; katkı vermez. +fn manual_capacity_error(parts: &[CustomPartition], disks: &[DiskInfo]) -> Option { + for d in disks { + let defs: Vec<&CustomPartition> = parts.iter() + .filter(|p| p.disk == d.name && !p.delete) + .collect(); + if defs.is_empty() { continue; } + let cap = d.size_bytes / 1_048_576; + let fixed: u64 = defs.iter().map(|p| p.size_mb).sum(); + if fixed > cap { + return Some(format!( + "{}: sabit tanımlar ({:.1} GB) disk boyutunu ({:.1} GB) aşıyor", + d.name, fixed as f64 / 1024.0, cap as f64 / 1024.0 + )); + } + } + None +} + impl InstallerStep for PartitionStep { fn name(&self) -> String { t!("partition").to_string() } @@ -379,6 +430,23 @@ impl InstallerStep for PartitionStep { let _ = theme::primary_button(ui, &t!("erase_disk_label")); if theme::secondary_button(ui, &t!("manual_partition_label")).clicked() { state.erase_disk = false; + // Manuel moda geçişte TÜM disklerin mevcut bölümlerini + // otomatik yükle; kullanıcı disk seçmek zorunda olmasın. + for d in &state.available_disks { + let fresh = read_existing_partitions(&d.name); + for f in fresh { + let known = state.custom_partitions.iter() + .any(|p| p.device == f.device || p.orig_device == f.orig_device); + if !known { + state.custom_partitions.push(f); + } + } + } + // Yeni bölümler için varsayılan hedef disk: ilk disk + if state.selected_disk.is_none() { + state.selected_disk = state.available_disks.first().map(|d| d.name.clone()); + } + assign_partition_numbers(&mut state.custom_partitions); } } else { if theme::secondary_button(ui, &t!("erase_disk_label")).clicked() { @@ -402,6 +470,7 @@ impl InstallerStep for PartitionStep { } else { !state.custom_partitions.is_empty() && validate_manual(&state.custom_partitions, &state.logical_volumes, self.is_uefi).is_none() + && manual_capacity_error(&state.custom_partitions, &state.available_disks).is_none() } } } @@ -489,8 +558,6 @@ impl PartitionStep { if response.clicked() && !too_small { state.selected_disk = Some(disk.name.clone()); - self.selected_disk_size_mb = Some(disk.size_bytes / 1_048_576); - eprintln!("DEBUG AUTO DISK SELECT: disk={} size_bytes={} size_mb={}", disk.name, disk.size_bytes, disk.size_bytes / 1_048_576); state.partition_plan = make_plan(disk, self.is_uefi); } @@ -717,10 +784,19 @@ impl PartitionStep { }); if response.clicked() { + // Aktif diski değiştir; ancak diğer disklerde yapılmış + // bölüm tanımlarını KORU. Seçilen diskin mevcut + // bölümlerini listeye ekle (aynı cihaz zaten tanımlıysa + // kullanıcının tanımı kazanır). state.selected_disk = Some(disk.name.clone()); - self.selected_disk_size_mb = Some(disk.size_bytes / 1_048_576); - eprintln!("DEBUG DISK SELECT: disk={} size_bytes={} size_mb={}", disk.name, disk.size_bytes, disk.size_bytes / 1_048_576); - state.custom_partitions = read_existing_partitions(&disk.name); + let fresh = read_existing_partitions(&disk.name); + for f in fresh { + let known = state.custom_partitions.iter() + .any(|p| p.device == f.device || p.orig_device == f.orig_device); + if !known { + state.custom_partitions.push(f); + } + } self.manual.error = None; } @@ -740,8 +816,7 @@ impl PartitionStep { return; } }; - let max_mb = self.selected_disk_size_mb.unwrap_or(256 * 1024) as f64; - eprintln!("DEBUG MAX_MB: selected_disk_size_mb={:?} max_mb={}", self.selected_disk_size_mb, max_mb); + let _ = &disk; // aktif disk (yeni bölümler buna eklenir) // ── Mevcut bölüm listesi tablosu ─────────────────────── ui.label(egui::RichText::new(t!("mp_plan_title")).strong().color(crate::ui::theme::c_text())); @@ -751,61 +826,175 @@ impl PartitionStep { let col_widths = [0.25, 0.12, 0.16, 0.24, 0.23]; let tam_genislik = ui.available_width(); - let header_color = egui::Color32::from_rgb(33, 37, 41); - let header_text = egui::Color32::WHITE; let _border_stroke = egui::Stroke::new(1.0_f32, crate::ui::theme::c_border()); - // ── Başlık satırı ───────────────────────────────── - let headers: [String; 5] = [ - t!("mp_device").to_string(), - t!("mp_size").to_string(), - t!("mp_fstype").to_string(), - t!("mp_mountpoint").to_string(), - t!("mp_format_status").to_string(), - ]; - ui.horizontal(|ui| { - ui.set_height(30.0); - ui.spacing_mut().item_spacing.x = 0.0; - for (j, h) in headers.into_iter().enumerate() { - let (rect, _) = ui.allocate_exact_size( - egui::vec2(tam_genislik * col_widths[j], 30.0), + // ── 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), + FreeSpace { disk: String, total_mb: u64, first_idx: usize }, + } + let mut visible_rows: Vec = Vec::new(); + { + let mut seen_disks: Vec<&str> = Vec::new(); + for part in &state.custom_partitions { + if !seen_disks.contains(&part.disk.as_str()) { + seen_disks.push(&part.disk); + } + } + for d in seen_disks { + // 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 hiç çizilmez. + let used_total: u64 = state.custom_partitions.iter() + .filter(|p| p.disk == d && !p.delete) + .map(|p| p.size_mb) + .sum(); + let free_total_mb = disk_size_mb(state, d).saturating_sub(used_total); + + let mut fs_first: Option = None; + for (gi, part) in state.custom_partitions.iter().enumerate() { + if part.disk != d { 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: d.to_string(), total_mb: free_total_mb, first_idx: 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: d.to_string(), total_mb: free_total_mb, first_idx: first, + }); + } + } + } + } + + let total_rows = visible_rows.len(); + let mut last_disk_header = String::new(); + for (vis_idx, row) in visible_rows.iter().enumerate() { + let (disk_name, global_idx, part): (&str, usize, Option<&CustomPartition>) = match row { + TableRow::Part(gi, p) => (p.disk.as_str(), *gi, Some(p)), + TableRow::FreeSpace { disk, first_idx, .. } => (disk.as_str(), *first_idx, None), + }; + // Disk grubu başlığı: yeni diske geçildiğinde bir kez çiz + if last_disk_header != disk_name { + last_disk_header = disk_name.to_string(); + let disk_total_mb = disk_size_mb(state, disk_name); + let disk_total_str = if disk_total_mb >= 1024 { + format!("{:.1} GB", disk_total_mb as f64 / 1024.0) + } else { + format!("{} MB", disk_total_mb) + }; + let is_active_disk = state.selected_disk.as_deref() == Some(disk_name); + let (hdr_rect, _) = ui.allocate_exact_size( + egui::vec2(tam_genislik, 26.0), egui::Sense::hover(), ); - let child_ui = &mut ui.child_ui(rect, egui::Layout::top_down(egui::Align::Center)); - egui::Frame::none() - .fill(header_color) - .stroke(egui::Stroke::new(1.0_f32, header_color)) - .inner_margin(egui::Margin::symmetric(8.0, 4.0)) - .show(child_ui, |ui| { - ui.label(egui::RichText::new(h).strong().color(header_text).size(13.0)); - }); - } - }); + let painter = ui.painter(); + painter.rect_filled( + hdr_rect, + egui::Rounding::ZERO, + crate::ui::theme::c_bg_widget(), + ); + painter.text( + egui::pos2(hdr_rect.left() + 10.0, hdr_rect.center().y), + egui::Align2::LEFT_CENTER, + if is_active_disk { + format!("\u{EA6B} {} ({})", disk_name, disk_total_str) + } else { + format!("{} ({})", disk_name, disk_total_str) + }, + egui::FontId::proportional(12.5), + crate::ui::theme::c_accent(), + ); - // ── Veri satırları ──────────────────────────────── - for (row_idx, part) in state.custom_partitions.iter().enumerate() { - let is_selected = self.manual.selected_idx == Some(row_idx); - let row_bg = if is_selected { + // Bu diskin kendi sütun başlıkları + draw_partition_table_header(ui, tam_genislik, col_widths); + } + + // ── Satır içeriği: bölüm mü, boş-alan satırı mı? ── + let (device_text, size_text, fstype_text, mount_text) = match part { + Some(p) => { + let size_str = if p.size_mb == 0 { + // Sembolik boyut ("kalan alan"): kurulumda diskin + // kalan boşluğunun tamamını alır → kapasiteyi göster. + let others: u64 = state.custom_partitions.iter() + .filter(|q| q.disk == p.disk && !q.delete && q.size_mb > 0) + .map(|q| q.size_mb) + .sum(); + let rem = disk_size_mb(state, &p.disk).saturating_sub(others); + if rem >= 1024 { + format!("{:.1} GB", rem as f64 / 1024.0) + } else { + format!("{} MB", rem) + } + } else if p.size_mb >= 1024 { + format!("{:.1} GB", p.size_mb as f64 / 1024.0) + } else { + format!("{} MB", p.size_mb) + }; + let mut fstype_label = p.fstype.label().to_string(); + if p.encrypt { + fstype_label = format!("🔐 {}", fstype_label); + } + ( + p.device.clone(), + size_str, + fstype_label, + p.mountpoint.clone(), + ) + } + None => { + // Boş Alan satırı: silinen tüm tanımların toplamı. + // TableRow::FreeSpace'ten total_mb'e ulaşmak için row'ı + // yeniden eşle (disk adı üzerinden değil). + let total_mb = match row { + TableRow::FreeSpace { total_mb, .. } => *total_mb, + _ => 0, + }; + let fs_str = if total_mb >= 1024 { + format!("{:.1} GB", total_mb as f64 / 1024.0) + } else { + format!("{} MB", total_mb) + }; + ( + format!("\u{EDFB} {}", t!("mp_free_space")), + fs_str, + String::new(), + String::new(), + ) + } + }; + + let is_selected = self.manual.selected_idx == Some(global_idx); + let is_free_row = part.is_none(); + let row_bg = if is_free_row { + egui::Color32::from_rgba_unmultiplied(180, 50, 40, 70) + } else if is_selected { egui::Color32::from_rgba_unmultiplied(80, 100, 180, 80) - } else if row_idx % 2 == 0 { + } else if vis_idx % 2 == 0 { egui::Color32::from_rgba_unmultiplied(60, 60, 80, 60) } else { egui::Color32::TRANSPARENT }; - let size_str = if part.size_mb == 0 { - t!("mp_size_remaining").to_string() - } else if part.size_mb >= 1024 { - format!("{:.1} GB", part.size_mb as f64 / 1024.0) - } else { - format!("{} MB", part.size_mb) - }; - - let mut fstype_label = part.fstype.label().to_string(); - if part.encrypt { - fstype_label = format!("🔐 {}", fstype_label); - } - // Entire row as a single clickable target (painter-based, no widget nesting) let (row_rect, row_response) = ui.allocate_exact_size( egui::vec2(tam_genislik, 34.0), @@ -813,7 +1002,7 @@ impl PartitionStep { ); if row_response.clicked() { - self.manual.selected_idx = Some(row_idx); + self.manual.selected_idx = Some(global_idx); } row_response.on_hover_cursor(egui::CursorIcon::PointingHand); @@ -823,11 +1012,16 @@ impl PartitionStep { // Paint cell texts directly (no child_ui/label widgets that steal clicks) let cell_texts = [ - part.device.as_str(), - size_str.as_str(), - fstype_label.as_str(), - part.mountpoint.as_str(), + device_text.as_str(), + size_text.as_str(), + fstype_text.as_str(), + mount_text.as_str(), ]; + let text_color = if is_free_row { + egui::Color32::from_rgb(255, 140, 130) + } else { + crate::ui::theme::c_text() + }; let mut x = row_rect.left(); for (j, text) in cell_texts.iter().enumerate() { let cell_w = tam_genislik * col_widths[j]; @@ -840,46 +1034,59 @@ impl PartitionStep { egui::Align2::CENTER_CENTER, *text, egui::FontId::proportional(13.0), - crate::ui::theme::c_text(), + text_color, ); x += cell_w; } // Format sütunu: ikon karakteri (konumlandırılmış) + // Boş-alan satırında ikon gösterilmez. let icon_cell_w = tam_genislik * col_widths[4]; - let icon_rect = egui::Rect::from_min_size( - egui::pos2(x + (icon_cell_w - 20.0) / 2.0, row_rect.top() + (34.0 - 20.0) / 2.0), - egui::vec2(20.0, 20.0), - ); - if part.skip_format { - // lock ikonu - ui.put(icon_rect, egui::Label::new( - egui::RichText::new("\u{EECD}") - .color(egui::Color32::from_rgb(180, 60, 40)) - .size(18.0) - )); - } else { - // check ikonu - ui.put(icon_rect, egui::Label::new( - egui::RichText::new("\u{EB7B}") - .color(egui::Color32::from_rgb(255, 251, 0)) - .size(18.0) - )); - }; + if let Some(p) = part { + let icon_rect = egui::Rect::from_min_size( + egui::pos2(x + (icon_cell_w - 20.0) / 2.0, row_rect.top() + (34.0 - 20.0) / 2.0), + egui::vec2(20.0, 20.0), + ); + if p.skip_format { + // lock ikonu + ui.put(icon_rect, egui::Label::new( + egui::RichText::new("\u{EECD}") + .color(egui::Color32::from_rgb(180, 60, 40)) + .size(18.0) + )); + } else { + // check ikonu + ui.put(icon_rect, egui::Label::new( + egui::RichText::new("\u{EB7B}") + .color(egui::Color32::from_rgb(255, 251, 0)) + .size(18.0) + )); + }; + } - if row_idx < state.custom_partitions.len() - 1 { + if vis_idx < total_rows - 1 { ui.separator(); } } - // ── Bölümleme Planı (manuel) ────────────────────────── + // ── Bölümleme Planı (manuel) — disk başına ESKİ/YENİ karşılaştırma ── if !state.custom_partitions.is_empty() { ui.add_space(12.0); ui.separator(); ui.add_space(8.0); ui.label(egui::RichText::new(t!("partition_plan_title")).strong().color(crate::ui::theme::c_text())); ui.add_space(6.0); - manual_partition_bar(ui, &state.custom_partitions); + + let mut seen_disks: Vec<&str> = Vec::new(); + for part in &state.custom_partitions { + if !seen_disks.contains(&part.disk.as_str()) { + seen_disks.push(&part.disk); + } + } + for d in seen_disks { + manual_dual_bars(ui, state, d); + ui.add_space(8.0); + } } // Silme onayı @@ -900,7 +1107,26 @@ impl PartitionStep { ui.horizontal(|ui| { if theme::danger_button(ui, &t!("mp_delete_confirm_yes")).clicked() { if idx < state.custom_partitions.len() { - state.custom_partitions.remove(idx); + let is_physical = !state.custom_partitions[idx].orig_device.is_empty(); + if is_physical { + // Eski Yali modeli (OperationDestroyDevice): + // bölüm kurulumda silinmek üzere İŞARETLENİR; + // listede görünür kalır, işaret geri alınabilir. + let p = &mut state.custom_partitions[idx]; + p.delete = !p.delete; + if p.delete { + p.mountpoint.clear(); + p.encrypt = false; + p.luks_password.clear(); + p.luks_name.clear(); + p.skip_format = true; + } + } else { + // Kullanıcının yeni eklediği tanım: + // listeden tamamen çıkar. + state.custom_partitions.remove(idx); + } + assign_partition_numbers(&mut state.custom_partitions); } self.manual.delete_idx = None; self.manual.selected_idx = None; @@ -920,12 +1146,25 @@ impl PartitionStep { let selected = self.manual.selected_idx .and_then(|i| state.custom_partitions.get(i)); + // Silinmiş (ataması kaldırılmış) fiziksel bölüm: mountpoint'i + // boş ama diskte gerçekten var. Bu satır için akış "Oluştur"dur. + let is_unassigned = selected + .map(|p| !p.orig_device.is_empty() && p.mountpoint.is_empty() && !p.delete) + .unwrap_or(false); + // Kurulumda silinmek üzere işaretlenmiş bölüm + let is_marked_delete = selected.map(|p| p.delete).unwrap_or(false); + // Yeni Bölüm if theme::primary_button(ui, &format!("\u{EA13} {}", t!("mp_new_partition"))).clicked() { self.manual.adding = true; self.manual.editing = false; + self.manual.create_over = false; self.manual.edit_idx = None; - let next_num = state.custom_partitions.len() + 1; + // Varsayılan öneriler, AKTİF diskteki tanım sayısına göre yapılır + let active_disk = state.selected_disk.clone().unwrap_or_default(); + let next_num = state.custom_partitions.iter() + .filter(|p| p.disk == active_disk) + .count() + 1; self.manual.add_mountpoint.clear(); self.manual.add_encrypt = false; self.manual.add_luks_password.clear(); @@ -953,33 +1192,81 @@ impl PartitionStep { self.manual.add_skip_format = false; } - // Düzenle - ui.add_enabled_ui(has_selection, |ui| { - if theme::secondary_button(ui, &format!("\u{EFE0} {}", t!("mp_edit_partition"))).clicked() { + // Düzenle / (silinmiş satırda) Oluştur — silinmek işaretli + // satırlarda düzenleme anlamsızdır, buton kilitlenir. + ui.add_enabled_ui(has_selection && !is_marked_delete, |ui| { + let edit_btn_label = if is_unassigned { + format!("\u{EA13} {}", t!("mp_create_here")) + } else { + format!("\u{EFE0} {}", t!("mp_edit_partition")) + }; + if theme::secondary_button(ui, &edit_btn_label).clicked() { if let Some(part) = selected { - self.manual.editing = true; - self.manual.adding = false; - self.manual.edit_idx = self.manual.selected_idx; - self.manual.add_size_str = if part.size_mb == 0 { - String::new() + if is_unassigned { + // Oluşturma akışı; ancak kaydedilince MEVCUT + // satırın üzerine yazılacak (edit_idx ayakta). + self.manual.create_over = true; + self.manual.editing = true; + self.manual.adding = false; + self.manual.edit_idx = self.manual.selected_idx; + self.manual.add_size_str.clear(); + self.manual.add_mountpoint.clear(); + self.manual.add_encrypt = false; + self.manual.add_luks_password.clear(); + self.manual.add_skip_format = false; } else { - part.size_mb.to_string() - }; - self.manual.add_mountpoint = part.mountpoint.clone(); - self.manual.add_fstype = FS_OPTIONS.iter() - .position(|(_, ft)| *ft == part.fstype) - .unwrap_or(0); - self.manual.add_encrypt = part.encrypt; - self.manual.add_luks_password = part.luks_password.clone(); - self.manual.add_skip_format = part.skip_format; + self.manual.editing = true; + self.manual.adding = false; + self.manual.create_over = false; + self.manual.edit_idx = self.manual.selected_idx; + self.manual.add_size_str = if part.size_mb == 0 { + String::new() + } else { + part.size_mb.to_string() + }; + self.manual.add_mountpoint = part.mountpoint.clone(); + self.manual.add_fstype = FS_OPTIONS.iter() + .position(|(_, ft)| *ft == part.fstype) + .unwrap_or(0); + self.manual.add_encrypt = part.encrypt; + self.manual.add_luks_password = part.luks_password.clone(); + self.manual.add_skip_format = part.skip_format; + } } } }); - // Sil + // Sil / Geri Al ui.add_enabled_ui(has_selection, |ui| { - if theme::danger_button(ui, &format!("\u{EC28} {}", t!("mp_delete"))).clicked() { - self.manual.delete_idx = self.manual.selected_idx; + let del_btn_label = if is_marked_delete { + format!("\u{EB7B} {}", t!("mp_restore")) + } else { + format!("\u{EC28} {}", t!("mp_delete")) + }; + if theme::danger_button(ui, &del_btn_label).clicked() { + if is_marked_delete { + // İşareti anında geri al (onay gerekmez). + // Boş-alan satırı seçiliyken basıldıysa diskteki + // TÜM silinmek işaretli tanımlar geri alınır. + if let Some(i) = self.manual.selected_idx { + if let Some(sel) = state.custom_partitions.get(i) { + let sel_disk = sel.disk.clone(); + let mut restored = false; + for p in state.custom_partitions.iter_mut() { + if p.disk == sel_disk && p.delete { + p.delete = false; + restored = true; + } + } + if restored { + assign_partition_numbers(&mut state.custom_partitions); + } + } + } + self.manual.selected_idx = None; + } else { + self.manual.delete_idx = self.manual.selected_idx; + } } }); }); @@ -988,7 +1275,11 @@ impl PartitionStep { let dialog_open = self.manual.adding || self.manual.editing; if dialog_open { let is_edit = self.manual.editing; - let title = if is_edit { t!("mp_edit_partition") } else { t!("mp_new_partition") }; + // create_over: silinmiş satırın üzerine oluşturma → sunum + // açısından "yeni bölüm" akışı gibi davranır (akıllı öneriler, + // Ekle butonu); kaydedince mevcut satırın üzerine yazar. + let is_create_like = !is_edit || self.manual.create_over; + let title = if is_create_like { t!("mp_new_partition") } else { t!("mp_edit_partition") }; egui::Window::new(title) .collapsible(false) .resizable(false) @@ -1007,7 +1298,7 @@ impl PartitionStep { } }); // FS tipi değişince akıllı boyut öner - if !is_edit && prev_fs != self.manual.add_fstype && self.manual.add_size_str.is_empty() { + if is_create_like && prev_fs != self.manual.add_fstype && self.manual.add_size_str.is_empty() { let fs = FS_OPTIONS.get(self.manual.add_fstype).map(|x| &x.1); match fs { Some(FsType::Fat32) => self.manual.add_size_str = "300".to_string(), @@ -1047,15 +1338,27 @@ impl PartitionStep { } }); // Slider — kullanılabilir alan kadar - let used_mb: u64 = state.custom_partitions.iter() - .enumerate() - .filter(|(i, p)| p.size_mb > 0 && Some(*i) != self.manual.edit_idx) - .map(|(_, p)| p.size_mb) - .sum(); - let avail_mb = (max_mb as u64).saturating_sub(used_mb).max(1); - eprintln!("DEBUG EDIT: max_mb={} used_mb={} avail_mb={} edit_idx={:?} partitions={:?}", - max_mb, used_mb, avail_mb, self.manual.edit_idx, - state.custom_partitions.iter().map(|p| (p.mountpoint.clone(), p.size_mb)).collect::>()); + // Hedef disk: düzenlemede bölümün kendi diski, + // eklemede aktif (seçili) disk. Boyut hesapları + // yalnızca bu disk üzerinden yapılır. + let dialog_disk = if is_edit { + self.manual.edit_idx + .and_then(|i| state.custom_partitions.get(i)) + .map(|p| p.disk.clone()) + .unwrap_or_default() + } else { + state.selected_disk.clone().unwrap_or_default() + }; + let disk_total_mb = disk_size_mb(state, &dialog_disk); + let used_mb: u64 = used_mb_on_disk(&state.custom_partitions, &dialog_disk, self.manual.edit_idx); + let avail_mb = disk_total_mb.saturating_sub(used_mb).max(1); + let avail_gb = avail_mb as f64 / 1024.0; + ui.label( + egui::RichText::new(format!("{} \u{EA6B} {} (≈ {:.1} GB {})", + dialog_disk, format_number_thousands(avail_mb), avail_gb, t!("mp_size_available"))) + .size(11.0) + .color(crate::ui::theme::c_text_dim()), + ); let mut size_val = self.manual.add_size_str.trim().parse::().unwrap_or(0.0); ui.add_space(2.0); if ui.add(egui::Slider::new(&mut size_val, 0.0..=avail_mb as f64) @@ -1193,23 +1496,25 @@ impl PartitionStep { ui.add_space(8.0); ui.separator(); ui.horizontal(|ui| { - let ok_label = if is_edit { t!("mp_save") } else { t!("mp_add_btn") }; + let ok_label = if is_create_like { t!("mp_add_btn") } else { t!("mp_save") }; if theme::primary_button(ui, &ok_label).clicked() { + // Hedef disk: düzenlemede bölümün diski, eklemede aktif disk + let target_disk = if is_edit { + self.manual.edit_idx + .and_then(|i| state.custom_partitions.get(i)) + .map(|p| p.disk.clone()) + .unwrap_or_else(|| disk.clone()) + } else { + disk.clone() + }; let size_str = self.manual.add_size_str.trim(); let size_mb: u64 = if size_str == "remaining" { - // Kalan alanı hesapla - if let Some(disk_size) = self.selected_disk_size_mb { - let used: u64 = state.custom_partitions.iter() - .enumerate() - .filter(|(i, _)| Some(*i) != self.manual.edit_idx) - .map(|(_, p)| p.size_mb) - .sum(); - let remaining = disk_size.saturating_sub(used); - eprintln!("DEBUG REMAINING: disk_size={} used={} remaining={}", disk_size, used, remaining); - if remaining > 0 { remaining } else { disk_size } - } else { - 0 - } + // SEMBOLİK kalır (size_mb = 0): gerçek boyut + // kurulum anında, diskteki silme/oluşturma + // işlemleri bitince boş alana göre belirlenir. + // Somut sayıya çevirmek, sonradan başka bölüm + // boyutları değişirse taşmaya yol açar. + 0 } else { size_str.parse().unwrap_or(0) }; @@ -1220,13 +1525,16 @@ impl PartitionStep { if mp.is_empty() { self.manual.error = Some(t!("mp_error_empty_mountpoint").to_string()); + } else if target_disk.is_empty() { + self.manual.error = Some(t!("mp_select_disk_first").to_string()); } else if is_edit { if let Some(idx) = self.manual.edit_idx { if idx < state.custom_partitions.len() { - let device = state.custom_partitions[idx].device.clone(); let skip_format = self.manual.add_skip_format; state.custom_partitions[idx] = CustomPartition { - device, + disk: state.custom_partitions[idx].disk.clone(), + device: state.custom_partitions[idx].device.clone(), + orig_device: state.custom_partitions[idx].orig_device.clone(), size_mb, fstype, mountpoint: mp, @@ -1234,18 +1542,23 @@ impl PartitionStep { luks_password, luks_name: String::new(), skip_format, + delete: false, // düzenleme işareti temizler }; + assign_partition_numbers(&mut state.custom_partitions); } } self.manual.editing = false; + self.manual.create_over = false; self.manual.edit_idx = None; self.manual.selected_idx = None; self.manual.error = None; } else { - let part_num = state.custom_partitions.len() + 1; - let device = crate::installer::part_path(&disk, part_num as u32); + // Yeni bölüm aktif diske eklenir; bölüm numarası + // o diskteki mevcut tanım sayısından türetilir. state.custom_partitions.push(CustomPartition { - device, + disk: target_disk.clone(), + device: String::new(), // renumber ile atanır + orig_device: String::new(), // fiziksel karşılığı yok size_mb, fstype, mountpoint: mp, @@ -1253,7 +1566,9 @@ impl PartitionStep { luks_password, luks_name: String::new(), skip_format: self.manual.add_skip_format, + delete: false, }); + assign_partition_numbers(&mut state.custom_partitions); self.manual.add_encrypt = false; self.manual.add_luks_password.clear(); self.manual.error = None; @@ -1263,6 +1578,7 @@ impl PartitionStep { if theme::secondary_button(ui, &t!("cancel")).clicked() { self.manual.adding = false; self.manual.editing = false; + self.manual.create_over = false; self.manual.edit_idx = None; } }); @@ -1498,7 +1814,12 @@ impl PartitionStep { ui.colored_label(egui::Color32::from_rgb(217, 0, 91), err); } if !state.custom_partitions.is_empty() { - if let Some(warn) = validate_manual(&state.custom_partitions, &state.logical_volumes, self.is_uefi) { + if let Some(warn) = manual_capacity_error(&state.custom_partitions, &state.available_disks) { + ui.colored_label( + egui::Color32::from_rgb(217, 0, 91), + format!("\u{ECA1} {}", warn), + ); + } else if let Some(warn) = validate_manual(&state.custom_partitions, &state.logical_volumes, self.is_uefi) { ui.colored_label( egui::Color32::from_rgb(220, 160, 40), format!("\u{ECA1} {}", warn), @@ -1518,55 +1839,64 @@ impl PartitionStep { // Yardımcı: renkli bölüm şeridi (manuel) // ───────────────────────────────────────────── -fn manual_partition_bar(ui: &mut egui::Ui, parts: &[CustomPartition]) { +/// Bölüm tablosunun sütun başlıklarını çizer. +/// Her disk grubu kendi başlık satırıyla ayrı bir "tablo" görünümü verir. +fn draw_partition_table_header(ui: &mut egui::Ui, tam_genislik: f32, col_widths: [f32; 5]) { + let header_color = egui::Color32::from_rgb(33, 37, 41); + let header_text = egui::Color32::WHITE; + let headers: [String; 5] = [ + t!("mp_device").to_string(), + t!("mp_size").to_string(), + t!("mp_fstype").to_string(), + t!("mp_mountpoint").to_string(), + t!("mp_format_status").to_string(), + ]; + ui.horizontal(|ui| { + ui.set_height(30.0); + ui.spacing_mut().item_spacing.x = 0.0; + for (j, h) in headers.into_iter().enumerate() { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(tam_genislik * col_widths[j], 30.0), + egui::Sense::hover(), + ); + let child_ui = &mut ui.child_ui(rect, egui::Layout::top_down(egui::Align::Center)); + egui::Frame::none() + .fill(header_color) + .stroke(egui::Stroke::new(1.0_f32, header_color)) + .inner_margin(egui::Margin::symmetric(8.0, 4.0)) + .show(child_ui, |ui| { + ui.label(egui::RichText::new(h).strong().color(header_text).size(13.0)); + }); + } + }); +} + +/// Tek bir düzen şeridi çizer: (etiket, boyut_mb, renk) segmentleri. +/// Etiketi boş olan segmentler gri "boş alan" olarak gösterilir. +fn draw_layout_bar(ui: &mut egui::Ui, segments: &[(String, u64, egui::Color32)]) { let bar_height = 28.0; let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width(), bar_height), egui::Sense::hover(), ); let painter = ui.painter(); - - if parts.is_empty() { + if segments.is_empty() { return; } - // Each partition gets at least MIN_VISIBLE of the bar so all are visible - let sized_total: f32 = parts.iter().map(|p| p.size_mb as f32).sum(); + let sized_total: f32 = segments.iter().map(|s| s.1 as f32).sum(); + if sized_total <= 0.0 { return; } let min_visible = 0.04; - let mut raw: Vec = parts.iter().map(|p| { - if sized_total > 0.0 { - (p.size_mb as f32 / sized_total).max(min_visible) - } else { - min_visible - } - }).collect(); - - // Normalize to sum 1.0 + let mut raw: Vec = segments.iter() + .map(|s| (s.1 as f32 / sized_total).max(min_visible)) + .collect(); let sum: f32 = raw.iter().sum(); - for v in raw.iter_mut() { - *v /= sum; - } - - let mut segments: Vec<(&str, f32, egui::Color32)> = Vec::new(); - - for (i, part) in parts.iter().enumerate() { - let label = part.mountpoint.as_str(); - let color = if part.fstype == FsType::Fat32 { - egui::Color32::from_rgb(80, 160, 220) - } else if part.fstype == FsType::Swap { - egui::Color32::from_rgb(220, 160, 40) - } else if part.mountpoint == "/" { - egui::Color32::from_rgb(80, 190, 100) - } else { - egui::Color32::from_rgb(160, 130, 200) - }; - segments.push((label, raw[i], color)); - } + for v in raw.iter_mut() { *v /= sum; } let mut x = rect.left(); let rounding = egui::Rounding::same(4.0); - for (i, (label, frac, color)) in segments.iter().enumerate() { + for (i, ((label, _, color), frac)) in segments.iter().zip(raw.iter()).enumerate() { let w = frac * rect.width(); let seg_rect = egui::Rect::from_min_size( egui::pos2(x, rect.top()), @@ -1580,17 +1910,102 @@ fn manual_partition_bar(ui: &mut egui::Ui, parts: &[CustomPartition]) { egui::Rounding::ZERO }; painter.rect_filled(seg_rect, seg_rounding, *color); - painter.text( - seg_rect.center(), - egui::Align2::CENTER_CENTER, - *label, - egui::FontId::proportional(12.0), - egui::Color32::WHITE, - ); + if !label.is_empty() { + painter.text( + seg_rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(12.0), + egui::Color32::WHITE, + ); + } x += w; } } +/// Bir tanımın şerit etiketi ve rengi. +fn bar_style(part: &CustomPartition) -> (String, egui::Color32) { + let label = if part.mountpoint.is_empty() { + part.fstype.label().to_string() + } else { + part.mountpoint.clone() + }; + let color = if part.fstype == FsType::Fat32 { + egui::Color32::from_rgb(80, 160, 220) + } else if part.fstype == FsType::Swap { + egui::Color32::from_rgb(220, 160, 40) + } else if part.fstype == FsType::Lvm || part.fstype == FsType::LvmLv { + egui::Color32::from_rgb(60, 170, 170) + } else if part.mountpoint == "/" { + egui::Color32::from_rgb(80, 190, 100) + } else { + egui::Color32::from_rgb(160, 130, 200) + }; + (label, color) +} + +const BAR_FREE_COLOR: egui::Color32 = egui::Color32::from_rgb(90, 90, 105); + +/// Seçili disk için ESKİ (mevcut) ve YENİ (kurulum sonrası) düzen +/// şeritlerini üst üste çizer — eski Yali/GParted karşılaştırma görünümü. +fn manual_dual_bars(ui: &mut egui::Ui, state: &GlobalState, disk: &str) { + let defs: Vec<&CustomPartition> = state.custom_partitions.iter() + .filter(|p| p.disk == disk) + .collect(); + if defs.is_empty() { return; } + + let disk_mb = disk_size_mb(state, disk); + + // ── Eski düzen: fiziksel bölümler (silinmek işaretliler dâhil — + // onlar hâlâ diskte duruyorlar) ── + let mut old_segs: Vec<(String, u64, egui::Color32)> = defs.iter() + .filter(|p| !p.orig_device.is_empty()) + .map(|p| { + let (label, color) = bar_style(p); + (label, p.size_mb.max(1), color) + }) + .collect(); + + // ── Yeni düzen: silinmeyecekler + yeni tanımlar + kalan boşluk ── + let mut new_segs: Vec<(String, u64, egui::Color32)> = defs.iter() + .filter(|p| !p.delete) + .map(|p| { + let (label, color) = bar_style(p); + (label, if p.size_mb == 0 { 0 } else { p.size_mb }, color) + }) + .collect(); + // Sembolik ("kalan") tanım varsa kalan boşluğun tamamını o alır + let has_symbolic = defs.iter().any(|p| !p.delete && p.size_mb == 0 && p.orig_device.is_empty()); + let fixed_new: u64 = new_segs.iter().map(|s| s.1).sum(); + let free_mb = disk_mb.saturating_sub(fixed_new); + + if has_symbolic { + // Kalan alanı sembolik tanımın segmentine yaz + for s in new_segs.iter_mut() { + if s.1 == 0 { s.1 = free_mb; break; } + } + } else if free_mb > 0 { + new_segs.push((String::new(), free_mb, BAR_FREE_COLOR)); + } + + // Eski barda hiç segment yoksa disk tamamen boş demektir + if old_segs.is_empty() { + old_segs.push((String::new(), disk_mb, BAR_FREE_COLOR)); + } + + ui.label(egui::RichText::new(format!("\u{EDFB} {}", disk)) + .size(12.5).color(crate::ui::theme::c_accent())); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(t!("mp_bar_now")).size(11.5).color(crate::ui::theme::c_text_dim())); + draw_layout_bar(ui, &old_segs); + }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(t!("mp_bar_after")).size(11.5).color(crate::ui::theme::c_text_dim())); + draw_layout_bar(ui, &new_segs); + }); +} + // ───────────────────────────────────────────── // Yardımcı: renkli bölüm şeridi (otomatik) // ───────────────────────────────────────────── @@ -1683,7 +2098,9 @@ mod tests { fn part(device: &str, size_mb: u64, fstype: FsType, mountpoint: &str) -> CustomPartition { CustomPartition { + disk: crate::installer::device_to_disk(device), device: device.to_string(), + orig_device: device.to_string(), size_mb, fstype, mountpoint: mountpoint.to_string(), @@ -1691,6 +2108,7 @@ mod tests { luks_password: String::new(), luks_name: String::new(), skip_format: false, + delete: false, } } @@ -1700,7 +2118,6 @@ mod tests { is_uefi: uefi, manual: ManualState::default(), show_luks_password: false, - selected_disk_size_mb: None, } } @@ -1804,6 +2221,115 @@ mod tests { assert!(st.is_complete(&state), "geçerli düzen ile tamamlanmalı"); } + // ── device_to_disk / assign_partition_numbers ─────────────── + + #[test] + fn device_to_disk_variants() { + assert_eq!(crate::installer::device_to_disk("/dev/sda1"), "/dev/sda"); + assert_eq!(crate::installer::device_to_disk("/dev/sdb12"), "/dev/sdb"); + assert_eq!(crate::installer::device_to_disk("/dev/nvme0n1p2"), "/dev/nvme0n1"); + assert_eq!(crate::installer::device_to_disk("/dev/mmcblk0p1"), "/dev/mmcblk0"); + assert_eq!(crate::installer::device_to_disk("/dev/vda3"), "/dev/vda"); + } + + #[test] + fn renumber_keeps_disks_independent() { + let mut parts = vec![ + part("/dev/sda1", 300, FsType::Fat32, "/boot/efi"), + part("/dev/sdb1", 20_000, FsType::Ext4, "/"), + part("/dev/sda2", 4096, FsType::Swap, "swap"), + ]; + // sdb'deki tek tanım silinirse numaralar disk bazlı kaymalı + parts.remove(1); + assign_partition_numbers(&mut parts); + assert_eq!(parts[0].device, "/dev/sda1"); + assert_eq!(parts[0].disk, "/dev/sda"); + assert_eq!(parts[1].device, "/dev/sda2"); + assert_eq!(parts[1].disk, "/dev/sda"); + } + + #[test] + fn deleted_slot_is_reused_by_new_partition() { + // Eski Yali modeli: silinmek işaretlenen sda2'nin slotu boşalır; + // yeni tanım en küçük boş slotu (2) alır; sda3 yerinde kalır. + let mut parts = vec![ + part("/dev/sda1", 300, FsType::Fat32, "/boot/efi"), + part("/dev/sda2", 4096, FsType::Swap, "swap"), + part("/dev/sda3", 20_000, FsType::Ext4, "/"), + ]; + parts[1].delete = true; + parts.push({ + let mut p = part("/dev/sda4", 8192, FsType::Ext4, ""); + p.orig_device = String::new(); // yeni tanım + p.device = String::new(); + p + }); + assign_partition_numbers(&mut parts); + assert_eq!(parts[0].device, "/dev/sda1"); // fiziksel: korunur + assert!(parts[1].delete); // işaret: dokunulmaz + assert_eq!(parts[2].device, "/dev/sda3"); // yerinde kalır + assert_eq!(parts[3].device, "/dev/sda2"); // yeni: boş slotu alır + } + + #[test] + fn used_mb_excludes_deleted_partitions() { + let mut parts = vec![ + part("/dev/sda1", 1024, FsType::Fat32, "/boot/efi"), + part("/dev/sda2", 4096, FsType::Swap, "swap"), + ]; + parts[1].delete = true; + // Silinmek işaretli bölüm boş alan sayılır + assert_eq!(used_mb_on_disk(&parts, "/dev/sda", None), 1024); + } + + #[test] + fn validate_ignores_deleted_root() { + let mut parts = vec![part("/dev/sda1", 20_000, FsType::Ext4, "/")]; + assert!(validate_manual(&parts, &[], false).is_none()); + parts[0].delete = true; + assert!( + validate_manual(&parts, &[], false).is_some(), + "kök silinecekse plan geçersiz olmalı" + ); + } + + #[test] + fn capacity_error_on_overflow() { + let disks = [disk("/dev/sdb", 25)]; + let mut s1 = part("/dev/sdb1", 1024, FsType::Fat32, "/boot/efi"); + let mut s2 = part("/dev/sdb2", 3886, FsType::Swap, "swap"); + let mut s3 = part("/dev/sdb3", 21_096, FsType::Ext4, "/"); + for p in [&mut s1, &mut s2, &mut s3] { p.orig_device = String::new(); } + // 1024+3886+21096 = 26006 > 25600 MB → hata + assert!(manual_capacity_error(&[s1.clone(), s2.clone(), s3.clone()], &disks).is_some()); + // Kök sembolikse ("kalan alan") sorun yok + s3.size_mb = 0; + assert!(manual_capacity_error(&[s1, s2, s3], &disks).is_none()); + } + + #[test] + fn used_mb_on_disk_isolates_disks() { + let parts = vec![ + part("/dev/sda1", 1024, FsType::Fat32, "/boot/efi"), + part("/dev/sdb1", 4096, FsType::Swap, "swap"), + part("/dev/sda2", 8192, FsType::Ext4, "/"), + ]; + assert_eq!(used_mb_on_disk(&parts, "/dev/sda", None), 1024 + 8192); + assert_eq!(used_mb_on_disk(&parts, "/dev/sdb", None), 4096); + // Düzenlenen bölüm (idx 0) hariç tutulunca kalan 8192 olmalı + assert_eq!(used_mb_on_disk(&parts, "/dev/sda", Some(0)), 8192); + } + + #[test] + fn disk_size_mb_reads_available_disks() { + let mut state = crate::installer::GlobalState::default(); + state.available_disks.push(disk("/dev/sda", 25)); + state.available_disks.push(disk("/dev/nvme0n1", 100)); + assert_eq!(disk_size_mb(&state, "/dev/sda"), 25 * 1024); + assert_eq!(disk_size_mb(&state, "/dev/nvme0n1"), 100 * 1024); + assert_eq!(disk_size_mb(&state, "/dev/yok"), 0); + } + // ── format_number_thousands ─────────────────────────────── #[test] diff --git a/src/steps/summary.rs b/src/steps/summary.rs index f9f6341..f763f04 100644 --- a/src/steps/summary.rs +++ b/src/steps/summary.rs @@ -43,15 +43,25 @@ impl InstallerStep for SummaryStep { }; summary_row(ui, t!("summary_keyboard"), &kb); - let disk_str = match &state.selected_disk { - Some(d) => { - if state.erase_disk { - format!("{} \u{EA6B} {}", d, t!("summary_erase_mode")) - } else { - format!("{} \u{EA6B} {}", d, t!("summary_manual_mode")) + let disk_str = if state.erase_disk { + match &state.selected_disk { + Some(d) => format!("{} \u{EA6B} {}", d, t!("summary_erase_mode")), + None => t!("summary_not_selected").to_string(), + } + } else if !state.custom_partitions.is_empty() { + // Manuel modda tanım yapılan TÜM diskleri listele + let mut disks: Vec<&str> = Vec::new(); + for part in &state.custom_partitions { + if !disks.contains(&part.disk.as_str()) { + disks.push(&part.disk); } } - None => t!("summary_not_selected").to_string(), + format!("{} \u{EA6B} {}", disks.join(", "), t!("summary_manual_mode")) + } else { + match &state.selected_disk { + Some(d) => format!("{} \u{EA6B} {}", d, t!("summary_manual_mode")), + None => t!("summary_not_selected").to_string(), + } }; summary_row(ui, t!("summary_disk"), &disk_str); @@ -73,15 +83,45 @@ impl InstallerStep for SummaryStep { } } else if !state.custom_partitions.is_empty() { let mut plan_lines = Vec::new(); + // Bölümleri ait oldukları diske göre gruplu listele + let mut seen_disks: Vec<&str> = Vec::new(); for part in &state.custom_partitions { - let size_str = if part.size_mb == 0 { - t!("mp_size_remaining").to_string() - } else if part.size_mb >= 1024 { - format!("{:.1} GB", part.size_mb as f64 / 1024.0) - } else { - format!("{} MB", part.size_mb) - }; - plan_lines.push(format!("{} \u{EA6B} {} ({}, {})", part.device, part.mountpoint, part.fstype, size_str)); + if !seen_disks.contains(&part.disk.as_str()) { + seen_disks.push(&part.disk); + } + } + for d in seen_disks { + plan_lines.push(format!("\u{EDFB} {}", d)); + for part in state.custom_partitions.iter().filter(|p| p.disk == d) { + let size_str = if part.size_mb == 0 { + // Sembolik boyut: kalan kapasiteyi göster + let others: u64 = state.custom_partitions.iter() + .filter(|q| q.disk == part.disk && !q.delete && q.size_mb > 0) + .map(|q| q.size_mb) + .sum(); + let cap = state.available_disks.iter() + .find(|dk| dk.name == part.disk) + .map(|dk| dk.size_bytes / 1_048_576) + .unwrap_or(0); + let rem = cap.saturating_sub(others); + if rem >= 1024 { + format!("{:.1} GB", rem as f64 / 1024.0) + } else { + format!("{} MB", rem) + } + } else if part.size_mb >= 1024 { + format!("{:.1} GB", part.size_mb as f64 / 1024.0) + } else { + format!("{} MB", part.size_mb) + }; + if part.delete { + plan_lines.push(format!("{} \u{EA6B} {} ({}, {}) \u{ECA1} {}", + part.device, "—", "—", size_str, t!("mp_will_delete"))); + } else { + plan_lines.push(format!("{} \u{EA6B} {} ({}, {})", + part.device, part.mountpoint, part.fstype, size_str)); + } + } } summary_row(ui, t!("summary_custom_partitions"), &plan_lines.join("\n")); }