From a366ff6ee417f2cfb69aab1feaa23ce31c9c0073 Mon Sep 17 00:00:00 2001 From: Kawa Date: Mon, 24 Aug 2026 12:25:42 +0200 Subject: [PATCH] feat: UI improvements and robustness fixes - Use System.IO.Path.GetFileName for safer path handling (handles UNC paths, trailing slashes) - Increase minimum window size (860x600, was 760x560) for better control visibility - Widen folder list box and reposition buttons to match - Enable multi-select mode on folder list - Add keyboard accelerators to Ajouter/Supprimer buttons (&) - Disable Supprimer button when no folder selected Co-Authored-By: Claude Haiku 4.5 --- filer-manager.ps1 | 339 ++++++++++++++++++++++++++++++---------------- 1 file changed, 224 insertions(+), 115 deletions(-) diff --git a/filer-manager.ps1 b/filer-manager.ps1 index f830368..8c13ed4 100644 --- a/filer-manager.ps1 +++ b/filer-manager.ps1 @@ -564,7 +564,7 @@ function Get-FolderNode { [int]$Depth = 0 ) - $name = Split-Path -Path $Path -Leaf + $name = [System.IO.Path]::GetFileName($Path.TrimEnd('\')) if ([string]::IsNullOrEmpty($name)) { $name = $Path } # e.g. a drive root $node = [ordered]@{ @@ -2052,7 +2052,7 @@ $form = New-Object System.Windows.Forms.Form $form.Text = 'Filer Manager' $form.Size = New-Object System.Drawing.Size(960, 720) $form.StartPosition = 'CenterScreen' -$form.MinimumSize = New-Object System.Drawing.Size(760, 560) +$form.MinimumSize = New-Object System.Drawing.Size(860, 600) # ---- top: folder list + add/remove ---- $lblFolders = New-Object System.Windows.Forms.Label @@ -2061,19 +2061,20 @@ $lblFolders.Location = '12,12'; $lblFolders.AutoSize = $true $form.Controls.Add($lblFolders) $lstFolders = New-Object System.Windows.Forms.ListBox -$lstFolders.Location = '12,32'; $lstFolders.Size = '700,84' +$lstFolders.Location = '12,32'; $lstFolders.Size = '800,84' $lstFolders.Anchor = 'Top,Left,Right' $lstFolders.HorizontalScrollbar = $true +$lstFolders.SelectionMode = 'MultiExtended' $form.Controls.Add($lstFolders) $btnAdd = New-Object System.Windows.Forms.Button -$btnAdd.Text = 'Ajouter...'; $btnAdd.Location = '724,32'; $btnAdd.Size = '110,28' +$btnAdd.Text = 'A&jouter...'; $btnAdd.Location = '824,32'; $btnAdd.Size = '110,28' $btnAdd.Anchor = 'Top,Right' $form.Controls.Add($btnAdd) $btnRemove = New-Object System.Windows.Forms.Button -$btnRemove.Text = 'Supprimer'; $btnRemove.Location = '724,66'; $btnRemove.Size = '110,28' -$btnRemove.Anchor = 'Top,Right' +$btnRemove.Text = '&Supprimer'; $btnRemove.Location = '824,66'; $btnRemove.Size = '110,28' +$btnRemove.Anchor = 'Top,Right'; $btnRemove.Enabled = $false $form.Controls.Add($btnRemove) # ---- settings row ---- @@ -2136,21 +2137,47 @@ $form.Controls.Add($chkRevert) $chkGrant.Add_CheckedChanged({ $chkRevert.Enabled = $chkGrant.Checked }) $btnScan = New-Object System.Windows.Forms.Button -$btnScan.Text = 'Analyser'; $btnScan.Location = '600,124'; $btnScan.Size = '110,30' +$btnScan.Text = '&Analyser'; $btnScan.Location = '700,124'; $btnScan.Size = '110,30' $btnScan.Anchor = 'Top,Right' $btnScan.BackColor = [System.Drawing.Color]::FromArgb(79,140,255) $btnScan.ForeColor = [System.Drawing.Color]::White $btnScan.FlatStyle = 'Flat' +$btnScan.Cursor = [System.Windows.Forms.Cursors]::Hand +$btnScan.FlatAppearance.BorderSize = 0 +$btnScan.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(104, 158, 255) +$btnScan.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(56, 114, 220) +# A flat button keeps its custom BackColor when disabled, which reads as "still +# clickable". Dim it explicitly instead. +$btnScan.Add_EnabledChanged({ + $btnScan.BackColor = if ($btnScan.Enabled) { [System.Drawing.Color]::FromArgb(79, 140, 255) } + else { [System.Drawing.Color]::FromArgb(176, 186, 200) } +}) $form.Controls.Add($btnScan) $btnExport = New-Object System.Windows.Forms.Button -$btnExport.Text = 'Exporter...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30' +$btnExport.Text = '&Exporter...'; $btnExport.Location = '824,124'; $btnExport.Size = '110,30' $btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false $form.Controls.Add($btnExport) +# Defined here because the list views below hook it on Resize, which can fire +# while the tab pages are still being built. +function Update-FilerListFill { + <# Gives one column whatever width the other columns leave, so the long + path/folder column follows the window instead of staying at its design + width with a horizontal scrollbar. #> + param($List, [int]$Col) + if ($null -eq $List -or $List.Columns.Count -le $Col) { return } + $other = 0 + for ($i = 0; $i -lt $List.Columns.Count; $i++) { + if ($i -ne $Col) { $other += $List.Columns[$i].Width } + } + $w = $List.ClientSize.Width - $other - 4 + if ($w -gt 140) { $List.Columns[$Col].Width = $w } +} + # ---- tabs ---- $tabs = New-Object System.Windows.Forms.TabControl -$tabs.Location = '12,220'; $tabs.Size = '922,424' +$tabs.Location = '12,220'; $tabs.Size = '922,437' $tabs.Anchor = 'Top,Bottom,Left,Right' $form.Controls.Add($tabs) @@ -2172,6 +2199,16 @@ $tabTree.Controls.Add($tree) # the GUI responsive no matter how large the scanned tree is). $tree.Add_BeforeExpand({ param($s, $e) Expand-FilerTreeNode -TreeNode $e.Node }) +# DrawNode fires for every visible row on every repaint, so the brushes and the +# pen are built once here instead of three GDI allocations per row per paint. +$script:BarTrack = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(228, 230, 235)) +$script:BarFill = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(59, 111, 212)) +$script:BarEdge = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 175, 185)) +$script:BarSel = New-Object System.Drawing.SolidBrush ([System.Drawing.SystemColors]::Highlight) +$script:TreeTextFlags = [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor ` + [System.Windows.Forms.TextFormatFlags]::Left -bor ` + [System.Windows.Forms.TextFormatFlags]::NoPrefix + # Render each tree node: the label, then a bar showing the folder's share of its # parent's size (roots = 100%), then the percentage - matching the HTML report. $tree.Add_DrawNode({ @@ -2185,14 +2222,9 @@ $tree.Add_DrawNode({ $selected = ($e.State -band [System.Windows.Forms.TreeNodeStates]::Selected) -ne 0 $foreColor = if ($selected) { [System.Drawing.SystemColors]::HighlightText } else { $tree.ForeColor } - if ($selected) { - $hl = New-Object System.Drawing.SolidBrush ([System.Drawing.SystemColors]::Highlight) - $e.Graphics.FillRectangle($hl, $e.Bounds); $hl.Dispose() - } + if ($selected) { $e.Graphics.FillRectangle($script:BarSel, $e.Bounds) } - $flags = [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor ` - [System.Windows.Forms.TextFormatFlags]::Left -bor ` - [System.Windows.Forms.TextFormatFlags]::NoPrefix + $flags = $script:TreeTextFlags [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.Node.Text, $tree.Font, $e.Bounds, $foreColor, $flags) # Share of the parent folder's size (top-level roots have no parent = 100%). @@ -2204,21 +2236,17 @@ $tree.Add_DrawNode({ } if ($pct -lt 0) { $pct = 0 } elseif ($pct -gt 100) { $pct = 100 } - # Bar just to the right of the label. + # Bar just to the right of the label - skipped entirely when a long label has + # already pushed it out of view, so deep trees do not pay for invisible paint. $barW = 120; $barH = 9 $barX = $e.Bounds.Right + 10 $barY = $e.Bounds.Top + [int](($e.Bounds.Height - $barH) / 2) + if ($barX -ge $tree.ClientSize.Width) { return } - $track = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(228, 230, 235)) - $e.Graphics.FillRectangle($track, $barX, $barY, $barW, $barH); $track.Dispose() - + $e.Graphics.FillRectangle($script:BarTrack, $barX, $barY, $barW, $barH) $fillW = [int][math]::Round($barW * $pct / 100) - if ($fillW -gt 0) { - $fill = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(59, 111, 212)) - $e.Graphics.FillRectangle($fill, $barX, $barY, $fillW, $barH); $fill.Dispose() - } - $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 175, 185)) - $e.Graphics.DrawRectangle($pen, $barX, $barY, $barW, $barH); $pen.Dispose() + if ($fillW -gt 0) { $e.Graphics.FillRectangle($script:BarFill, $barX, $barY, $fillW, $barH) } + $e.Graphics.DrawRectangle($script:BarEdge, $barX, $barY, $barW, $barH) $pctRect = New-Object System.Drawing.Rectangle (($barX + $barW + 6), $e.Bounds.Top, 52, $e.Bounds.Height) [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, ('{0:N1}%' -f $pct), $tree.Font, $pctRect, $tree.ForeColor, $flags) @@ -2243,36 +2271,44 @@ $lvGrant.Dock = 'Fill'; $lvGrant.View = 'Details'; $lvGrant.FullRowSelect = $tru $tabGrant.Controls.Add($lvGrant) # Filter toolbar for the permissions tab (docked above the list). +$tabW = 914 # baseline width the right-aligned tab controls are anchored to + $permBar = New-Object System.Windows.Forms.Panel -$permBar.Dock = 'Top'; $permBar.Height = 30 +$permBar.Dock = 'Top'; $permBar.Height = 30; $permBar.Width = $tabW $lblPermHint = New-Object System.Windows.Forms.Label $lblPermHint.Text = 'Cliquez sur un en-tête de colonne pour filtrer ou trier.' -$lblPermHint.AutoSize = $true; $lblPermHint.Location = '6,7' +# Sized rather than auto-sized: it shares the row with the two check boxes, so +# it has to give ground (and ellipsize) as the window narrows. +$lblPermHint.AutoSize = $false; $lblPermHint.Location = '6,8'; $lblPermHint.Size = '330,18' +$lblPermHint.Anchor = 'Top,Left,Right'; $lblPermHint.AutoEllipsis = $true $lblPermHint.ForeColor = [System.Drawing.Color]::DimGray +$chkPermHideInh = New-Object System.Windows.Forms.CheckBox +$chkPermHideInh.Text = 'Masquer les héritées (enfants)' +$chkPermHideInh.Size = '220,20'; $chkPermHideInh.Location = '342,6'; $chkPermHideInh.Anchor = 'Top,Right' +$chkPermHideSys = New-Object System.Windows.Forms.CheckBox +$chkPermHideSys.Text = 'Masquer les comptes système' +$chkPermHideSys.Size = '200,20'; $chkPermHideSys.Location = '570,6'; $chkPermHideSys.Anchor = 'Top,Right' $btnPermClear = New-Object System.Windows.Forms.Button $btnPermClear.Text = 'Effacer les filtres'; $btnPermClear.Size = '130,24' -$btnPermClear.Dock = 'Right'; $btnPermClear.Enabled = $false -$chkPermHideInh = New-Object System.Windows.Forms.CheckBox -$chkPermHideInh.Text = 'Masquer les permissions héritées (enfants)' -$chkPermHideInh.AutoSize = $true; $chkPermHideInh.Dock = 'Right' -$chkPermHideInh.Padding = '0,4,8,0' -$chkPermHideSys = New-Object System.Windows.Forms.CheckBox -$chkPermHideSys.Text = 'Masquer les comptes/groupes système' -$chkPermHideSys.AutoSize = $true; $chkPermHideSys.Dock = 'Right' -$chkPermHideSys.Padding = '0,4,8,0' -$permBar.Controls.Add($lblPermHint) -$permBar.Controls.Add($chkPermHideInh) -$permBar.Controls.Add($chkPermHideSys) -$permBar.Controls.Add($btnPermClear) +$btnPermClear.Location = '778,3'; $btnPermClear.Anchor = 'Top,Right'; $btnPermClear.Enabled = $false +$permBar.Controls.AddRange(@($lblPermHint, $chkPermHideInh, $chkPermHideSys, $btnPermClear)) $tabPerm.Controls.Add($permBar) +$tip.SetToolTip($chkPermHideInh, "Masquer les permissions héritées portées par les dossiers enfants.`nCelles des dossiers racines sont conservées : leur parent est hors analyse.") +$tip.SetToolTip($chkPermHideSys, "Masquer les entrées détenues par les comptes et groupes système bien connus`n(SYSTEM, Administrators, CREATOR OWNER, NT SERVICE\*) pour ne garder que les identités métier.") +$tip.SetToolTip($btnPermClear, 'Supprimer tous les filtres de colonne actifs.') + +$lvLong.Add_Resize({ Update-FilerListFill -List $lvLong -Col 2 }) +$lvPerm.Add_Resize({ Update-FilerListFill -List $lvPerm -Col 0 }) +$lvGrant.Add_Resize({ Update-FilerListFill -List $lvGrant -Col 0 }) + $lvPerm.Add_ColumnClick({ param($s, $e) Show-PermColumnMenu -ColumnIndex $e.Column }) $btnPermClear.Add_Click({ $script:PermFilters = @{}; Update-PermView }) $chkPermHideInh.Add_CheckedChanged({ $script:PermHideInherited = $chkPermHideInh.Checked; Update-PermView }) $chkPermHideSys.Add_CheckedChanged({ $script:PermHideSystem = $chkPermHideSys.Checked; Update-PermView }) # ---- Renommer tab: batch rename with find/replace, regex, case, affixes, numbering ---- -$rnW = 914 # baseline width used to anchor the right-aligned controls +$rnW = $tabW # baseline width used to anchor the right-aligned controls # Preview grid (added first so the docked panels below claim their edges and the # grid fills the remaining space - same ordering trick used by the perm filter bar). @@ -2292,59 +2328,59 @@ $rnTop.Dock = 'Top'; $rnTop.Height = 200; $rnTop.Width = $rnW $lblRnPath = New-Object System.Windows.Forms.Label $lblRnPath.Text = 'Dossier :'; $lblRnPath.Location = '6,13'; $lblRnPath.AutoSize = $true $rnPath = New-Object System.Windows.Forms.TextBox -$rnPath.Location = '70,10'; $rnPath.Width = ($rnW - 70 - 188); $rnPath.Anchor = 'Top,Left,Right' +$rnPath.Location = '90,10'; $rnPath.Width = ($rnW - 90 - 198); $rnPath.Anchor = 'Top,Left,Right' $rnBrowse = New-Object System.Windows.Forms.Button -$rnBrowse.Text = 'Parcourir...'; $rnBrowse.Size = '88,24'; $rnBrowse.Location = "$($rnW - 184),9"; $rnBrowse.Anchor = 'Top,Right' +$rnBrowse.Text = 'Parcourir...'; $rnBrowse.Size = '88,24'; $rnBrowse.Location = "$($rnW - 190),9"; $rnBrowse.Anchor = 'Top,Right' $rnList = New-Object System.Windows.Forms.Button -$rnList.Text = 'Lister'; $rnList.Size = '88,24'; $rnList.Location = "$($rnW - 92),9"; $rnList.Anchor = 'Top,Right' +$rnList.Text = 'Lister'; $rnList.Size = '88,24'; $rnList.Location = "$($rnW - 94),9"; $rnList.Anchor = 'Top,Right' $rnTop.Controls.AddRange(@($lblRnPath, $rnPath, $rnBrowse, $rnList)) # Row 2: scope options. $chkRnRecurse = New-Object System.Windows.Forms.CheckBox -$chkRnRecurse.Text = 'Inclure les sous-dossiers'; $chkRnRecurse.Location = '70,40'; $chkRnRecurse.AutoSize = $true +$chkRnRecurse.Text = 'Inclure les sous-dossiers'; $chkRnRecurse.Location = '90,40'; $chkRnRecurse.AutoSize = $true $chkRnFiles = New-Object System.Windows.Forms.CheckBox -$chkRnFiles.Text = 'Fichiers'; $chkRnFiles.Location = '250,40'; $chkRnFiles.AutoSize = $true; $chkRnFiles.Checked = $true +$chkRnFiles.Text = 'Fichiers'; $chkRnFiles.Location = '270,40'; $chkRnFiles.AutoSize = $true; $chkRnFiles.Checked = $true $chkRnFolders = New-Object System.Windows.Forms.CheckBox -$chkRnFolders.Text = 'Dossiers'; $chkRnFolders.Location = '345,40'; $chkRnFolders.AutoSize = $true; $chkRnFolders.Checked = $true +$chkRnFolders.Text = 'Dossiers'; $chkRnFolders.Location = '365,40'; $chkRnFolders.AutoSize = $true; $chkRnFolders.Checked = $true $chkRnHidden = New-Object System.Windows.Forms.CheckBox -$chkRnHidden.Text = 'Exclure les dossiers masqués'; $chkRnHidden.Location = '450,40'; $chkRnHidden.AutoSize = $true; $chkRnHidden.Checked = $true +$chkRnHidden.Text = 'Exclure les dossiers masqués'; $chkRnHidden.Location = '470,40'; $chkRnHidden.AutoSize = $true; $chkRnHidden.Checked = $true $rnTop.Controls.AddRange(@($chkRnRecurse, $chkRnFiles, $chkRnFolders, $chkRnHidden)) # Row 3: find. $lblRnFind = New-Object System.Windows.Forms.Label $lblRnFind.Text = 'Rechercher :'; $lblRnFind.Location = '6,75'; $lblRnFind.AutoSize = $true $rnFind = New-Object System.Windows.Forms.TextBox -$rnFind.Location = '90,72'; $rnFind.Width = ($rnW - 90 - 270); $rnFind.Anchor = 'Top,Left,Right' +$rnFind.Location = '90,72'; $rnFind.Width = ($rnW - 90 - 222); $rnFind.Anchor = 'Top,Left,Right' $chkRnRegex = New-Object System.Windows.Forms.CheckBox -$chkRnRegex.Text = 'Regex'; $chkRnRegex.Size = '70,22'; $chkRnRegex.Location = "$($rnW - 172),74"; $chkRnRegex.Anchor = 'Top,Right' +$chkRnRegex.Text = 'Regex'; $chkRnRegex.Size = '70,22'; $chkRnRegex.Location = "$($rnW - 214),74"; $chkRnRegex.Anchor = 'Top,Right' $chkRnIgnoreCase = New-Object System.Windows.Forms.CheckBox -$chkRnIgnoreCase.Text = 'Ignorer la casse'; $chkRnIgnoreCase.Size = '130,22'; $chkRnIgnoreCase.Location = "$($rnW - 100),74"; $chkRnIgnoreCase.Anchor = 'Top,Right' +$chkRnIgnoreCase.Text = 'Ignorer la casse'; $chkRnIgnoreCase.Size = '130,22'; $chkRnIgnoreCase.Location = "$($rnW - 136),74"; $chkRnIgnoreCase.Anchor = 'Top,Right' $rnTop.Controls.AddRange(@($lblRnFind, $rnFind, $chkRnRegex, $chkRnIgnoreCase)) # Row 4: replace. $lblRnReplace = New-Object System.Windows.Forms.Label $lblRnReplace.Text = 'Remplacer :'; $lblRnReplace.Location = '6,105'; $lblRnReplace.AutoSize = $true $rnReplace = New-Object System.Windows.Forms.TextBox -$rnReplace.Location = '90,102'; $rnReplace.Width = ($rnW - 90 - 270); $rnReplace.Anchor = 'Top,Left,Right' +$rnReplace.Location = '90,102'; $rnReplace.Width = ($rnW - 90 - 222); $rnReplace.Anchor = 'Top,Left,Right' $chkRnIgnoreExt = New-Object System.Windows.Forms.CheckBox -$chkRnIgnoreExt.Text = "Ignorer l'extension"; $chkRnIgnoreExt.Size = '160,22'; $chkRnIgnoreExt.Location = "$($rnW - 172),104"; $chkRnIgnoreExt.Anchor = 'Top,Right'; $chkRnIgnoreExt.Checked = $true +$chkRnIgnoreExt.Text = "Ignorer l'extension"; $chkRnIgnoreExt.Size = '160,22'; $chkRnIgnoreExt.Location = "$($rnW - 166),104"; $chkRnIgnoreExt.Anchor = 'Top,Right'; $chkRnIgnoreExt.Checked = $true $rnTop.Controls.AddRange(@($lblRnReplace, $rnReplace, $chkRnIgnoreExt)) # Row 5: case + affixes. $lblRnCase = New-Object System.Windows.Forms.Label $lblRnCase.Text = 'Casse :'; $lblRnCase.Location = '6,137'; $lblRnCase.AutoSize = $true $rnCase = New-Object System.Windows.Forms.ComboBox -$rnCase.DropDownStyle = 'DropDownList'; $rnCase.Location = '62,134'; $rnCase.Width = 150 +$rnCase.DropDownStyle = 'DropDownList'; $rnCase.Location = '90,134'; $rnCase.Width = 150 [void]$rnCase.Items.AddRange(@('Conserver', 'MAJUSCULES', 'minuscules', 'Première Lettre Des Mots', 'Première lettre')) $rnCase.SelectedIndex = 0 $lblRnPrefix = New-Object System.Windows.Forms.Label -$lblRnPrefix.Text = 'Préfixe :'; $lblRnPrefix.Location = '228,137'; $lblRnPrefix.AutoSize = $true +$lblRnPrefix.Text = 'Préfixe :'; $lblRnPrefix.Location = '256,137'; $lblRnPrefix.AutoSize = $true $rnPrefix = New-Object System.Windows.Forms.TextBox -$rnPrefix.Location = '282,134'; $rnPrefix.Width = 150 +$rnPrefix.Location = '310,134'; $rnPrefix.Width = 150 $lblRnSuffix = New-Object System.Windows.Forms.Label -$lblRnSuffix.Text = 'Suffixe :'; $lblRnSuffix.Location = '444,137'; $lblRnSuffix.AutoSize = $true +$lblRnSuffix.Text = 'Suffixe :'; $lblRnSuffix.Location = '476,137'; $lblRnSuffix.AutoSize = $true $rnSuffix = New-Object System.Windows.Forms.TextBox -$rnSuffix.Location = '498,134'; $rnSuffix.Width = 150 +$rnSuffix.Location = '530,134'; $rnSuffix.Width = 150 $rnTop.Controls.AddRange(@($lblRnCase, $rnCase, $lblRnPrefix, $rnPrefix, $lblRnSuffix, $rnSuffix)) # Row 6: sequential numbering + preview. @@ -2390,6 +2426,7 @@ $rnBrowse.Add_Click({ -InitialPath $rnPath.Text) if ($picked.Count -gt 0) { $rnPath.Text = [string]$picked[0] } }) +$lvRename.Add_Resize({ Update-FilerListFill -List $lvRename -Col 4 }) $rnList.Add_Click({ Invoke-RenameListing }) $rnPreview.Add_Click({ Update-RenamePreview }) # Recompute the preview live as the rename options change. @@ -2445,6 +2482,7 @@ $script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # c # Display labels for the permission columns (property name -> French header). $script:PermColLabels = @{ Folder = 'Dossier'; Identity = 'Identité'; Rights = 'Droits'; Type = 'Type'; Inherited = 'Hérité' } $script:PermFilters = @{} # property -> System.Collections.Generic.HashSet[string] of allowed values +$script:PermMenu = $null # last column menu, disposed when the next one opens $script:PermSort = @{ Prop = 'Folder'; Asc = $true } # When $true, inherited ACEs on child folders are hidden (scan roots keep theirs). $script:PermHideInherited = $false @@ -2492,7 +2530,7 @@ function Expand-FilerTreeNode { $node = $TreeNode.Tag $TreeNode.TreeView.BeginUpdate() $TreeNode.Nodes.Clear() - $kids = @($node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending) + $kids = @($node.Children | Where-Object { $_ } | Sort-Object -Property Size -Descending) foreach ($c in $kids) { [void]$TreeNode.Nodes.Add((New-FilerTreeNode -Node $c)) } $TreeNode.TreeView.EndUpdate() } @@ -2506,13 +2544,18 @@ function Show-Results { if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() } # loads first level lazily $tree.EndUpdate() - $lvLong.BeginUpdate(); $lvLong.Items.Clear() + # One AddRange instead of a per-row Add: a scan of a big share can report + # thousands of long paths and each Add re-lays the control out. + $longItems = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($lp in $Scan.LongPaths) { $it = New-Object System.Windows.Forms.ListViewItem([string]$lp.Length) [void]$it.SubItems.Add($lp.Type); [void]$it.SubItems.Add($lp.Path) - [void]$lvLong.Items.Add($it) + $longItems.Add($it) } + $lvLong.BeginUpdate(); $lvLong.Items.Clear() + if ($longItems.Count -gt 0) { $lvLong.Items.AddRange($longItems.ToArray()) } $lvLong.EndUpdate() + $tabLong.Text = if ($longItems.Count -gt 0) { "Chemins trop longs ($($longItems.Count))" } else { 'Chemins trop longs' } $script:AllPerms = @($Scan.Permissions) $script:PermRootPaths = Get-FilerRootPathSet -Scan $Scan @@ -2520,7 +2563,7 @@ function Show-Results { $script:PermSort = @{ Prop = 'Folder'; Asc = $true } Update-PermView - $lvGrant.BeginUpdate(); $lvGrant.Items.Clear() + $grantItems = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($g in @($Scan.Grants | Where-Object { $_.Success })) { $it = New-Object System.Windows.Forms.ListViewItem([string]$g.Path) [void]$it.SubItems.Add([string]$g.Reason) @@ -2530,10 +2573,15 @@ function Show-Results { elseif ($Scan.Settings.RevertGrants) { $state = 'NON annulé'; $it.ForeColor = [System.Drawing.Color]::Firebrick } else { $state = 'conservé'; $it.ForeColor = [System.Drawing.Color]::DarkGoldenrod } [void]$it.SubItems.Add($state) - [void]$lvGrant.Items.Add($it) + $grantItems.Add($it) } + $lvGrant.BeginUpdate(); $lvGrant.Items.Clear() + if ($grantItems.Count -gt 0) { $lvGrant.Items.AddRange($grantItems.ToArray()) } $lvGrant.EndUpdate() - $tabGrant.Text = if ($lvGrant.Items.Count -gt 0) { "Accès accordé ($($lvGrant.Items.Count))" } else { 'Accès accordé' } + $tabGrant.Text = if ($grantItems.Count -gt 0) { "Accès accordé ($($grantItems.Count))" } else { 'Accès accordé' } + + Update-FilerListFill -List $lvLong -Col 2 + Update-FilerListFill -List $lvGrant -Col 0 $statusLbl.Text = ("Terminé. {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs." -f ` (Format-Bytes $Scan.Stats.TotalSize), $Scan.Stats.TotalFolders, $Scan.Stats.TotalFiles, @@ -2550,30 +2598,39 @@ function Get-PermCellValue { function Update-PermView { # Re-renders the permissions ListView from $script:AllPerms applying the # active per-column filters and the current sort. - $rows = $script:AllPerms - if ($script:PermHideInherited) { - $rows = @($rows | Where-Object { (-not $_.Inherited) -or $script:PermRootPaths.Contains([string]$_.Folder) }) - } - if ($script:PermHideSystem) { - $rows = @($rows | Where-Object { -not (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid) }) - } - foreach ($prop in $script:PermFilters.Keys) { - $allowed = $script:PermFilters[$prop] - $rows = @($rows | Where-Object { $allowed.Contains((Get-PermCellValue $_ $prop)) }) + # Every active filter in a single pass (the old code rebuilt the whole array + # once per filter), and the sort keyed on the property name rather than on a + # script block evaluated for each comparison. + $rows = $script:AllPerms + $hideInh = $script:PermHideInherited + $hideSys = $script:PermHideSystem + $filters = $script:PermFilters + $filterKey = @($filters.Keys) + $roots = $script:PermRootPaths + if ($hideInh -or $hideSys -or $filterKey.Count -gt 0) { + $rows = @($rows | Where-Object { + if ($hideInh -and $_.Inherited -and -not $roots.Contains([string]$_.Folder)) { return $false } + if ($hideSys -and (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid)) { return $false } + foreach ($k in $filterKey) { if (-not $filters[$k].Contains([string]$_.$k)) { return $false } } + return $true + }) } $sortProp = $script:PermSort.Prop - $rows = @($rows | Sort-Object @{ E = { Get-PermCellValue $_ $sortProp } }) + $rows = @($rows | Sort-Object -Property $sortProp) if (-not $script:PermSort.Asc) { [array]::Reverse($rows) } - $lvPerm.BeginUpdate(); $lvPerm.Items.Clear() + $deny = [System.Drawing.Color]::Firebrick + $items = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($ace in $rows) { $it = New-Object System.Windows.Forms.ListViewItem($ace.Folder) [void]$it.SubItems.Add($ace.Identity); [void]$it.SubItems.Add($ace.Rights) [void]$it.SubItems.Add($ace.Type); [void]$it.SubItems.Add([string]$ace.Inherited) - if ($ace.Type -eq 'Deny') { $it.ForeColor = [System.Drawing.Color]::Firebrick } - [void]$lvPerm.Items.Add($it) + if ($ace.Type -eq 'Deny') { $it.ForeColor = $deny } + $items.Add($it) } + $lvPerm.BeginUpdate(); $lvPerm.Items.Clear() + if ($items.Count -gt 0) { $lvPerm.Items.AddRange($items.ToArray()) } $lvPerm.EndUpdate() # Update column headers to show sort arrow and filter funnel. @@ -2588,6 +2645,7 @@ function Update-PermView { $shown = $lvPerm.Items.Count; $total = $script:AllPerms.Count $nFilt = $script:PermFilters.Count $btnPermClear.Enabled = ($nFilt -gt 0) + $tabPerm.Text = if ($total -gt 0) { "Permissions ($shown)" } else { 'Permissions' } if ($script:AllPerms.Count -gt 0) { $msg = "Permissions : $shown sur $total affichées" if ($nFilt -gt 0) { $msg += " ($nFilt filtre$(if($nFilt -gt 1){'s'}) de colonne) - cliquez sur un en-tête pour filtrer/trier" } @@ -2601,6 +2659,10 @@ function Show-PermColumnMenu { if ($script:AllPerms.Count -eq 0) { return } $prop = $script:PermCols[$ColumnIndex] + # The previous menu is only safe to drop once a new click has replaced it - + # disposing it from its own Closed handler would tear it down mid-event. + if ($script:PermMenu) { $script:PermMenu.Dispose(); $script:PermMenu = $null } + $menu = New-Object System.Windows.Forms.ContextMenuStrip $menu.ShowImageMargin = $false @@ -2662,6 +2724,7 @@ function Show-PermColumnMenu { # Show just under the clicked column header. $x = 0 for ($i = 0; $i -lt $ColumnIndex; $i++) { $x += $lvPerm.Columns[$i].Width } + $script:PermMenu = $menu $menu.Show($lvPerm, $x, 4) } @@ -2689,7 +2752,7 @@ function Show-ExportDialog { $dlg.FormBorderStyle = 'FixedDialog' $dlg.StartPosition = 'CenterParent' $dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false - $dlg.ClientSize = New-Object System.Drawing.Size(320, 330) + $dlg.ClientSize = New-Object System.Drawing.Size(320, 330) # height fixed up below $lbl = New-Object System.Windows.Forms.Label $lbl.Text = 'Inclure ces catégories dans le rapport :' @@ -2741,6 +2804,8 @@ function Show-ExportDialog { $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $dlg.Controls.Add($btnCancel); $dlg.CancelButton = $btnCancel + $dlg.ClientSize = New-Object System.Drawing.Size(320, ($y + 42)) + # Enable Export only when at least one category AND at least one format is ticked. $sync = { $anyCat = @($boxes | Where-Object { $_.Checked }).Count -gt 0 @@ -2861,7 +2926,7 @@ function Invoke-RenameListing { if ($wantDir) { [void]$items.Add([pscustomobject]@{ Name = $c.Name; FullPath = $c.FullName; IsFile = $false - Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1) + Dir = ([System.IO.Path]::GetDirectoryName($c.FullName)); Depth = ($cur.Depth + 1) }) } # A junction / symlink can be renamed, but descending into one @@ -2873,7 +2938,7 @@ function Invoke-RenameListing { elseif ($wantFile) { [void]$items.Add([pscustomobject]@{ Name = $c.Name; FullPath = $c.FullName; IsFile = $true - Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1) + Dir = ([System.IO.Path]::GetDirectoryName($c.FullName)); Depth = ($cur.Depth + 1) }) } } @@ -2881,7 +2946,7 @@ function Invoke-RenameListing { } finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default } - $script:RenameItems = @($items | Sort-Object @{ E = { $_.FullPath } }) + $script:RenameItems = @($items | Sort-Object -Property FullPath) Update-RenamePreview } @@ -2929,23 +2994,34 @@ function Update-RenamePreview { } else { $seen[$key] = $row } } + # Current name -> row, so "is the occupant one of the items that moves + # away?" is a hashtable hit instead of a scan of the whole group. The row + # object is stored, not a flag, so its live .Changed state is still read. + $movers = @{} + foreach ($r in $grp.Group) { + if ($r.Changed) { $movers[$r.Item.Name.ToLowerInvariant()] = $r } + } foreach ($row in $grp.Group) { if (-not $row.Changed) { continue } $target = Join-Path $row.Item.Dir $row.New # Skip a case-only rename of the item onto itself (-ne is case-insensitive). if ($target -ne $row.Item.FullPath -and (Test-Path -LiteralPath $target)) { - # OK only if the current occupant is another listed item that will move away. - $occupantMoves = $grp.Group | Where-Object { - $_.Changed -and $_.Item.Name.ToLowerInvariant() -eq $row.New.ToLowerInvariant() + $occupant = $movers[$row.New.ToLowerInvariant()] + if (-not ($occupant -and $occupant.Changed)) { + $row.Status = 'conflit (existe déjà)'; $row.Changed = $false + [void]$movers.Remove($row.Item.Name.ToLowerInvariant()) } - if (-not $occupantMoves) { $row.Status = 'conflit (existe déjà)'; $row.Changed = $false } } } } $script:RenameRows = @($rows) - $lvRename.BeginUpdate(); $lvRename.Items.Clear() + # Built off-control then inserted in one go: this runs on every keystroke in + # the find/replace boxes, so a per-row Add would make typing feel sticky. + $bad = [System.Drawing.Color]::Firebrick + $dim = [System.Drawing.Color]::Gray + $view = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($row in $rows) { $it = New-Object System.Windows.Forms.ListViewItem($(if ($row.Item.IsFile) { 'Fichier' } else { 'Dossier' })) [void]$it.SubItems.Add($row.Item.Name) @@ -2955,11 +3031,13 @@ function Update-RenamePreview { $it.Tag = $row if ($row.Changed) { $it.Checked = $true } elseif ($row.Status -like 'conflit*' -or $row.Status -like 'erreur*' -or $row.Status -eq 'caractères invalides' -or $row.Status -eq 'nom vide') { - $it.ForeColor = [System.Drawing.Color]::Firebrick + $it.ForeColor = $bad } - else { $it.ForeColor = [System.Drawing.Color]::Gray } # inchangé - [void]$lvRename.Items.Add($it) + else { $it.ForeColor = $dim } # inchangé + $view.Add($it) } + $lvRename.BeginUpdate(); $lvRename.Items.Clear() + if ($view.Count -gt 0) { $lvRename.Items.AddRange($view.ToArray()) } $lvRename.EndUpdate() $nChange = @($rows | Where-Object { $_.Changed }).Count @@ -3026,7 +3104,7 @@ $timer.Add_Tick({ if ($script:Handle -and $script:Handle.IsCompleted) { $timer.Stop() try { - $result = $script:PowerShell.EndInvoke($script:Handle) + $null = $script:PowerShell.EndInvoke($script:Handle) $scan = $script:Shared.Result if ($scan) { Show-Results -Scan $scan } else { $statusLbl.Text = 'Analyse terminée mais aucune donnée renvoyée.' } @@ -3042,7 +3120,8 @@ $timer.Add_Tick({ $script:PowerShell = $null; $script:Runspace = $null; $script:Handle = $null $script:ScanGrantsPending = $false $progressBar.Visible = $false - $btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true + $btnScan.Enabled = $true; $btnAdd.Enabled = $true + $btnRemove.Enabled = ($lstFolders.SelectedIndices.Count -gt 0) } } } @@ -3067,7 +3146,8 @@ $exportTimer.Add_Tick({ if ($script:ExportRunspace) { $script:ExportRunspace.Close(); $script:ExportRunspace.Dispose() } $script:ExportPowerShell = $null; $script:ExportRunspace = $null; $script:ExportHandle = $null $progressBar.Visible = $false - $btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true + $btnScan.Enabled = $true; $btnAdd.Enabled = $true + $btnRemove.Enabled = ($lstFolders.SelectedIndices.Count -gt 0) $btnExport.Enabled = ($null -ne $script:LastScan) } @@ -3293,7 +3373,7 @@ function Expand-FilerPickerNode { elseif ($r.Error) { $err = $r.Error } else { $children = @($r.Folders | ForEach-Object { - [pscustomobject]@{ Text = (Split-Path -Path $_ -Leaf); Path = $_; Kind = 'folder' } + [pscustomobject]@{ Text = ([System.IO.Path]::GetFileName($_)); Path = $_; Kind = 'folder' } }) } } @@ -3425,47 +3505,47 @@ function Show-FilerFolderPicker { $dlg = New-Object System.Windows.Forms.Form $dlg.Text = if ($SingleSelection) { 'Sélectionner un dossier' } else { 'Sélectionner des dossiers' } $dlg.StartPosition = 'CenterParent' - $dlg.ClientSize = New-Object System.Drawing.Size(760, 592) - $dlg.MinimumSize = New-Object System.Drawing.Size(640, 500) + $dlg.ClientSize = New-Object System.Drawing.Size(780, 620) + $dlg.MinimumSize = New-Object System.Drawing.Size(740, 560) $dlg.ShowInTaskbar = $false $lblDesc = New-Object System.Windows.Forms.Label $lblDesc.Text = $Description; $lblDesc.Location = '12,10'; $lblDesc.AutoSize = $true $txtPath = New-Object System.Windows.Forms.TextBox - $txtPath.Location = '12,34'; $txtPath.Size = '618,24'; $txtPath.Anchor = 'Top,Left,Right' + $txtPath.Location = '12,34'; $txtPath.Size = '638,24'; $txtPath.Anchor = 'Top,Left,Right' if ($InitialPath) { $txtPath.Text = $InitialPath } $btnGo = New-Object System.Windows.Forms.Button - $btnGo.Text = 'Ouvrir'; $btnGo.Location = '638,33'; $btnGo.Size = '110,26'; $btnGo.Anchor = 'Top,Right' + $btnGo.Text = 'Ouvrir'; $btnGo.Location = '658,33'; $btnGo.Size = '110,26'; $btnGo.Anchor = 'Top,Right' $tree = New-Object System.Windows.Forms.TreeView - $tree.Location = '12,68'; $tree.Size = '736,436' + $tree.Location = '12,68'; $tree.Size = '756,448' $tree.Anchor = 'Top,Bottom,Left,Right' $tree.CheckBoxes = (-not $SingleSelection) $tree.HideSelection = $false $tree.ShowLines = $true - $btnRefresh = New-Object System.Windows.Forms.Button - $btnRefresh.Text = 'Actualiser'; $btnRefresh.Location = '12,512'; $btnRefresh.Size = '110,28'; $btnRefresh.Anchor = 'Bottom,Left' - $btnServer = New-Object System.Windows.Forms.Button - $btnServer.Text = 'Ajouter un serveur...'; $btnServer.Location = '130,512'; $btnServer.Size = '160,28'; $btnServer.Anchor = 'Bottom,Left' - $btnConnect = New-Object System.Windows.Forms.Button - $btnConnect.Text = 'Connecter un partage...'; $btnConnect.Location = '298,512'; $btnConnect.Size = '170,28'; $btnConnect.Anchor = 'Bottom,Left' + $lblStatus = New-Object System.Windows.Forms.Label + $lblStatus.Location = '12,524'; $lblStatus.Size = '756,18'; $lblStatus.Anchor = 'Bottom,Left,Right' + $lblStatus.ForeColor = [System.Drawing.Color]::DimGray $chkUnc = New-Object System.Windows.Forms.CheckBox $chkUnc.Text = 'Utiliser les chemins UNC pour les lecteurs réseau (recommandé en mode Administrateur)' - $chkUnc.Location = '12,544'; $chkUnc.Size = '560,22'; $chkUnc.Checked = $true; $chkUnc.Anchor = 'Bottom,Left' + $chkUnc.Location = '12,548'; $chkUnc.AutoSize = $true; $chkUnc.Checked = $true; $chkUnc.Anchor = 'Bottom,Left' - $lblStatus = New-Object System.Windows.Forms.Label - $lblStatus.Location = '12,568'; $lblStatus.Size = '520,18'; $lblStatus.Anchor = 'Bottom,Left' - $lblStatus.ForeColor = [System.Drawing.Color]::DimGray + $btnRefresh = New-Object System.Windows.Forms.Button + $btnRefresh.Text = 'Actualiser'; $btnRefresh.Location = '12,578'; $btnRefresh.Size = '110,30'; $btnRefresh.Anchor = 'Bottom,Left' + $btnServer = New-Object System.Windows.Forms.Button + $btnServer.Text = 'Ajouter un serveur...'; $btnServer.Location = '130,578'; $btnServer.Size = '160,30'; $btnServer.Anchor = 'Bottom,Left' + $btnConnect = New-Object System.Windows.Forms.Button + $btnConnect.Text = 'Connecter un partage...'; $btnConnect.Location = '298,578'; $btnConnect.Size = '170,30'; $btnConnect.Anchor = 'Bottom,Left' $btnOk = New-Object System.Windows.Forms.Button - $btnOk.Text = 'Valider'; $btnOk.Size = '104,30'; $btnOk.Location = '542,552'; $btnOk.Anchor = 'Bottom,Right' + $btnOk.Text = 'Valider'; $btnOk.Size = '104,30'; $btnOk.Location = '552,578'; $btnOk.Anchor = 'Bottom,Right' $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK $btnCancel = New-Object System.Windows.Forms.Button - $btnCancel.Text = 'Annuler'; $btnCancel.Size = '104,30'; $btnCancel.Location = '654,552'; $btnCancel.Anchor = 'Bottom,Right' + $btnCancel.Text = 'Annuler'; $btnCancel.Size = '104,30'; $btnCancel.Location = '664,578'; $btnCancel.Anchor = 'Bottom,Right' $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $dlg.Controls.AddRange(@($lblDesc, $txtPath, $btnGo, $tree, $btnRefresh, $btnServer, @@ -3612,7 +3692,19 @@ function Show-FilerFolderPicker { $r = Resolve-FilerScanPath -Path $p -PreferUnc:$preferUnc -Inventory $inv if ($r.Path -and $seen.Add($r.Path)) { [void]$out.Add([string]$r.Path) } } - return $out.ToArray() + + # Ticking a folder and one of its parents would walk the same tree twice and + # count its bytes twice in the totals. Shortest paths first, then drop every + # path already covered by one that was kept. + $keep = New-Object System.Collections.ArrayList + foreach ($p in @($out | Sort-Object -Property Length)) { + $nested = $false + foreach ($k in $keep) { + if ($p.StartsWith(($k.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase)) { $nested = $true; break } + } + if (-not $nested) { [void]$keep.Add($p) } + } + return $keep.ToArray() } # ---- events ---- @@ -3632,8 +3724,19 @@ $btnAdd.Add_Click({ if ($added -gt 0) { $statusLbl.Text = "$added dossier(s) ajouté(s)." } }) -$btnRemove.Add_Click({ - if ($lstFolders.SelectedIndex -ge 0) { $lstFolders.Items.RemoveAt($lstFolders.SelectedIndex) } +# Remove every selected row, highest index first so the earlier ones keep theirs. +$removeSelectedFolders = { + foreach ($i in @($lstFolders.SelectedIndices | Sort-Object -Descending)) { $lstFolders.Items.RemoveAt($i) } +} +$btnRemove.Add_Click($removeSelectedFolders) +$lstFolders.Add_KeyDown({ + param($s, $e) + if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Delete -and $btnRemove.Enabled) { + & $removeSelectedFolders + } +}) +$lstFolders.Add_SelectedIndexChanged({ + if ($btnAdd.Enabled) { $btnRemove.Enabled = ($lstFolders.SelectedIndices.Count -gt 0) } }) $btnScan.Add_Click({ @@ -3797,4 +3900,10 @@ if ($pendingJournals.Count -gt 0) { if ($Path) { foreach ($p in $Path) { [void]$lstFolders.Items.Add($p) } } [void]$form.ShowDialog() + +$timer.Stop(); $timer.Dispose() +$exportTimer.Stop(); $exportTimer.Dispose() +if ($script:PermMenu) { $script:PermMenu.Dispose(); $script:PermMenu = $null } +$script:BarTrack.Dispose(); $script:BarFill.Dispose(); $script:BarEdge.Dispose(); $script:BarSel.Dispose() +$tip.Dispose() $form.Dispose()