feat: Renamen functionaliteit aangepast

This commit is contained in:
kodi
2026-03-12 09:38:14 +01:00
parent 8f4263c222
commit 2e897504a8
7 changed files with 324 additions and 10 deletions
+164
View File
@@ -0,0 +1,164 @@
# UI_F2_RENAME_V1
## 1. Doel
`Rename` moet een expliciete eigen UI-flow krijgen via `F2`, los van de bestaande `F6` move-flow.
Waarom:
- `Rename` is conceptueel een andere actie dan `Move`
- een eigen `F2`-flow sluit beter aan op klassieke file-manager bediening
- het maakt de UI voorspelbaarder: `F2` = naam wijzigen, `F6` = verplaatsen
- het vereenvoudigt later de verdere scheiding tussen rename en move in de UI, zonder backendwijziging
Bestaande context:
- rename-functionaliteit bestaat backendmatig al via het bestaande rename-endpoint
- move-functionaliteit bestaat backendmatig al via het bestaande move-endpoint
- `F6`/move-flow bestaat al in de UI
- in deze stap moet dus niets nieuws in backend of API worden ontworpen; alleen een nette eigen rename-flow in de frontend
## 2. Scope
In scope voor v1:
- exact 1 file geselecteerd
- exact 1 directory geselecteerd
- `F2` keyboard shortcut
- `Rename`-knop in de functiebalk
- compacte rename-popup
Out of scope:
- batch rename
- rename van meerdere selectie-items
- herontwerp van `F6`
- backendwijzigingen
- nieuwe dependencies
## 3. Gewenst gedrag
`F2` en de bestaande `Rename`-knop moeten exact dezelfde UI-flow gebruiken.
Voorstel:
- `F2` keyboard shortcut activeert de rename-flow
- `Rename`-knop in functiebalk activeert exact dezelfde flow
- beide openen een compacte rename-popup
Popup-eigenschappen:
- titel: `Rename`
- toont context van het geselecteerde item
- invoerveld bevat alleen de huidige naam
- dus niet het volledige pad
- focus direct in het invoerveld
- tekst vooraf geselecteerd zodat overschrijven snel kan
- `Enter` bevestigt
- `Escape` annuleert
- `X` rechtsboven sluit de popup
Belangrijk semantisch verschil met `F6`:
- `F2 Rename` werkt op naamniveau binnen dezelfde parent
- de popup toont en bewerkt alleen de naam
- geen destination pad, geen implicit move-semantiek
## 4. Validatie
Frontendvalidatie in v1 moet klein blijven en vooral duidelijke UX geven. De backend blijft de bron van waarheid.
Frontend moet minimaal blokkeren of afvangen:
- lege naam
- ongewijzigde naam
- namen met `/`
- namen die triviaal ongeldig zijn zoals `.` of `..`
Aanpak:
- lichte pre-validatie in de popup voor snelle feedback
- daarna altijd het bestaande rename-endpoint gebruiken
- backend-validatie blijft leidend voor definitieve afhandeling
Geen nieuwe rename-semantiek ontwerpen:
- geen padbewerkingen in de popup
- geen move-achtige fallback
- geen root- of parent-wijziging
## 5. Files en directories
Exact 1 file:
- rename toegestaan
- popup opent met huidige bestandsnaam
- bevestigen gebruikt bestaande backend-rename
Exact 1 directory:
- rename toegestaan
- popup opent met huidige mapnaam
- bevestigen gebruikt bestaande backend-rename
Meerdere geselecteerde items:
- in v1 niet ondersteunen
- `F2` en `Rename` doen functioneel niets destructiefs
- aanbevolen UX: knop disabled bij `selectedItems.length !== 1`
- voor keyboardshortcut: geen actie als rename in de huidige context disabled zou zijn
Dit sluit aan op de bestaande regel dat keyboardshortcuts dezelfde enabled/disabled toestand moeten respecteren als de functiebalkknoppen.
## 6. Relatie met bestaande flows
Herbruik:
- bestaande backend rename-functionaliteit hergebruiken
- bestaande move-functionaliteit ongemoeid laten
- bestaande selectie- en active-pane-logica hergebruiken
Regels:
- `F2` en `Rename`-knop delen exact dezelfde frontendflow
- `F6` blijft ongewijzigd in deze stap
- bestaande `F6` rename/move-popup wordt niet herontworpen in deze stap
- geen dubbele implementatie van backendlogica; alleen een aparte UI-laag voor rename
Pragmatische richting:
- introduceer een aparte compacte rename-popup
- submit roept hetzelfde backend-endpoint aan als de huidige renameknop al gebruikt
- succesvolle rename refresht alleen het actieve paneel, zoals nu al logisch is voor rename
## 7. Regressierisico
Belangrijkste risico's:
- selectieflow: `F2` mag niet reageren bij ongeldige selectie
- popup-focus: paneelkeyboard mag niet doorwerken terwijl de rename-popup open is
- interactie met `F6`: geen verwarring of gedeelde state tussen rename-popup en bestaande rename/move-popup
- onbedoeld herbouwen van bestaande rename/move-logica in plaats van hergebruik
Mitigatie:
- `F2` dezelfde disabled-context laten volgen als de `Rename`-knop
- aparte popup-state voor rename, niet hergebruik van de complexere `F6` destination-popup
- bestaande backend-rename endpoint direct blijven gebruiken
- geen aanpassing aan `F6` in deze stap
## 8. Teststrategie
UI smoke/regressietests:
- functiebalk bevat `Rename` met `F2`-hint
- rename-popupcontainer aanwezig in HTML
- rename-popup bevat invoerveld en sluitknop
- `F2` wiring aanwezig in frontendcode
- bestaande `F6` wiring blijft aanwezig
Handmatige validatie:
- exact 1 file geselecteerd -> `F2` opent rename-popup met alleen naam
- exact 1 directory geselecteerd -> `F2` opent rename-popup met alleen naam
- `Enter` bevestigt
- `Escape` sluit
- `X` sluit
- meerdere selectie -> `F2` doet niets / rename blijft disabled
- succesvolle rename refresht actief paneel
- `F6` move-flow blijft ongewijzigd werken
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- voeg een aparte compacte rename-popup toe voor `F2` en de `Rename`-knop
- werk alleen met de naam van exact 1 geselecteerd item
- gebruik het bestaande backend rename-endpoint zonder nieuwe semantiek
- laat `F2` en de `Rename`-knop exact dezelfde flow delen
- laat `F6` volledig ongemoeid in deze stap
Waarom dit de veiligste richting is:
- duidelijke scheiding tussen rename en move in de UI
- minimaal risico op regressie in bestaande move-flow
- geen backendwerk nodig
- sluit goed aan op klassieke file-manager verwachtingen
Binary file not shown.
@@ -38,7 +38,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="right-items"', body) self.assertIn('id="right-items"', body)
self.assertIn('id="function-bar"', body) self.assertIn('id="function-bar"', body)
self.assertIn('id="settings-btn"', body) self.assertIn('id="settings-btn"', body)
self.assertIn('id="rename-placeholder-btn"', body) self.assertIn('id="rename-btn"', body)
self.assertIn('id="view-btn"', body) self.assertIn('id="view-btn"', body)
self.assertIn('id="edit-btn"', body) self.assertIn('id="edit-btn"', body)
self.assertIn("F1", body) self.assertIn("F1", body)
@@ -51,6 +51,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn("F8", body) self.assertIn("F8", body)
self.assertIn('id="viewer-modal"', body) self.assertIn('id="viewer-modal"', body)
self.assertIn('id="settings-modal"', body) self.assertIn('id="settings-modal"', body)
self.assertIn('id="rename-popup"', body)
self.assertIn('id="rename-input"', body)
self.assertIn('id="rename-apply-btn"', body)
self.assertIn('id="settings-general-tab"', body) self.assertIn('id="settings-general-tab"', body)
self.assertIn('id="settings-logs-tab"', body) self.assertIn('id="settings-logs-tab"', body)
self.assertIn('id="settings-logs-list"', body) self.assertIn('id="settings-logs-list"', body)
@@ -77,20 +80,16 @@ class UiSmokeGoldenTest(unittest.TestCase):
ordered_ids = [ ordered_ids = [
'id="settings-btn"', 'id="settings-btn"',
'id="rename-placeholder-btn"', 'id="rename-btn"',
'id="view-btn"', 'id="view-btn"',
'id="edit-btn"', 'id="edit-btn"',
'id="copy-btn"', 'id="copy-btn"',
'id="move-btn"', 'id="move-btn"',
'id="rename-btn"',
'id="mkdir-btn"', 'id="mkdir-btn"',
'id="delete-btn"', 'id="delete-btn"',
] ]
positions = [body.index(marker) for marker in ordered_ids] positions = [body.index(marker) for marker in ordered_ids]
self.assertEqual(positions, sorted(positions)) self.assertEqual(positions, sorted(positions))
rename_placeholder_index = body.index('id="rename-placeholder-btn"')
disabled_index = body.index("disabled", rename_placeholder_index)
self.assertLess(rename_placeholder_index, disabled_index)
def test_ui_static_assets_are_present_and_mapped(self) -> None: def test_ui_static_assets_are_present_and_mapped(self) -> None:
mount = self._ui_mount() mount = self._ui_mount()
@@ -106,6 +105,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('if (event.key === "F1") {', app_js) self.assertIn('if (event.key === "F1") {', app_js)
self.assertIn('if (event.key === "F2") {', app_js) self.assertIn('if (event.key === "F2") {', app_js)
self.assertIn('function openSettings(tab = "general")', app_js) self.assertIn('function openSettings(tab = "general")', app_js)
self.assertIn('function openRenamePopup()', app_js)
self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js)
self.assertIn('return triggerActionButton("rename-btn");', app_js)
self.assertIn('await apiRequest("GET", "/api/history")', app_js) self.assertIn('await apiRequest("GET", "/api/history")', app_js)
self.assertIn('Cross-root directory move is not supported in v1', app_js) self.assertIn('Cross-root directory move is not supported in v1', app_js)
self.assertIn('Batch directory move is not supported in v1', app_js) self.assertIn('Batch directory move is not supported in v1', app_js)
+126 -2
View File
@@ -34,6 +34,10 @@ let renameMoveState = {
source: null, source: null,
destination: "", destination: "",
}; };
let renameState = {
source: null,
name: "",
};
let batchMoveState = { let batchMoveState = {
destinationBase: "", destinationBase: "",
count: 0, count: 0,
@@ -147,6 +151,17 @@ function renameMoveElements() {
}; };
} }
function renameElements() {
return {
overlay: document.getElementById("rename-popup"),
input: document.getElementById("rename-input"),
error: document.getElementById("rename-error"),
applyButton: document.getElementById("rename-apply-btn"),
cancelButton: document.getElementById("rename-cancel-btn"),
closeButton: document.getElementById("rename-close-btn"),
};
}
function batchMoveElements() { function batchMoveElements() {
return { return {
overlay: document.getElementById("batch-move-popup"), overlay: document.getElementById("batch-move-popup"),
@@ -920,7 +935,7 @@ function actionShortcutHandled(event) {
return triggerActionButton("settings-btn"); return triggerActionButton("settings-btn");
} }
if (event.key === "F2") { if (event.key === "F2") {
return false; return triggerActionButton("rename-btn");
} }
if (event.key === "F3") { if (event.key === "F3") {
return triggerActionButton("view-btn"); return triggerActionButton("view-btn");
@@ -991,6 +1006,10 @@ function isRenameMovePopupOpen() {
return !renameMoveElements().overlay.classList.contains("hidden"); return !renameMoveElements().overlay.classList.contains("hidden");
} }
function isRenamePopupOpen() {
return !renameElements().overlay.classList.contains("hidden");
}
function isBatchMovePopupOpen() { function isBatchMovePopupOpen() {
return !batchMoveElements().overlay.classList.contains("hidden"); return !batchMoveElements().overlay.classList.contains("hidden");
} }
@@ -1076,6 +1095,38 @@ function resetRenameMoveState() {
}; };
} }
function resetRenameState() {
renameState = {
source: null,
name: "",
};
}
function closeRenamePopup() {
const elements = renameElements();
elements.overlay.classList.add("hidden");
elements.error.textContent = "";
elements.input.value = "";
resetRenameState();
}
function openRenamePopup() {
const selectedItems = activePaneState().selectedItems;
if (selectedItems.length !== 1) {
return false;
}
const source = selectedItems[0];
const elements = renameElements();
renameState.source = source;
renameState.name = source.name;
elements.input.value = source.name;
elements.error.textContent = "";
elements.overlay.classList.remove("hidden");
elements.input.focus();
elements.input.select();
return true;
}
function resetBatchMoveState() { function resetBatchMoveState() {
batchMoveState = { batchMoveState = {
destinationBase: "", destinationBase: "",
@@ -1154,6 +1205,45 @@ function openF6Flow() {
return openBatchMovePopup(selectedItems); return openBatchMovePopup(selectedItems);
} }
async function submitRenamePopup() {
const elements = renameElements();
const source = renameState.source;
if (!source) {
return;
}
const newName = elements.input.value.trim();
elements.error.textContent = "";
if (!newName) {
elements.error.textContent = "Name is required";
return;
}
if (newName === source.name) {
elements.error.textContent = "Name must differ from current name";
return;
}
if (newName.includes("/")) {
elements.error.textContent = "Name cannot contain /";
return;
}
if (newName === "." || newName === "..") {
elements.error.textContent = "Invalid name";
return;
}
try {
await apiRequest("POST", "/api/files/rename", {
path: source.path,
new_name: newName,
});
closeRenamePopup();
setSelectedItem(state.activePane, null);
await loadBrowsePane(state.activePane);
setStatus(`Renamed ${source.path}`);
} catch (err) {
elements.error.textContent = err.message;
}
}
async function submitRenameMovePopup() { async function submitRenameMovePopup() {
const elements = renameMoveElements(); const elements = renameMoveElements();
const source = renameMoveState.source; const source = renameMoveState.source;
@@ -1532,6 +1622,19 @@ function clearSelectionForActivePane() {
} }
function handleKeyboardShortcuts(event) { function handleKeyboardShortcuts(event) {
if (isRenamePopupOpen()) {
if (event.key === "Escape") {
event.preventDefault();
closeRenamePopup();
return;
}
if (event.key === "Enter") {
event.preventDefault();
submitRenamePopup();
return;
}
return;
}
if (isSettingsOpen()) { if (isSettingsOpen()) {
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
@@ -1679,12 +1782,33 @@ function setupEvents() {
document.getElementById("settings-btn").onclick = () => openSettings("general"); document.getElementById("settings-btn").onclick = () => openSettings("general");
document.getElementById("view-btn").onclick = openViewer; document.getElementById("view-btn").onclick = openViewer;
document.getElementById("edit-btn").onclick = openEditor; document.getElementById("edit-btn").onclick = openEditor;
document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("rename-btn").onclick = openRenamePopup;
document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("delete-btn").onclick = deleteSelected;
document.getElementById("copy-btn").onclick = startCopySelected; document.getElementById("copy-btn").onclick = startCopySelected;
document.getElementById("move-btn").onclick = openF6Flow; document.getElementById("move-btn").onclick = openF6Flow;
document.getElementById("mkdir-btn").onclick = createFolderForActivePane; document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
const rename = renameElements();
rename.closeButton.onclick = closeRenamePopup;
rename.cancelButton.onclick = closeRenamePopup;
rename.applyButton.onclick = submitRenamePopup;
rename.input.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
submitRenamePopup();
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeRenamePopup();
}
};
rename.overlay.onclick = (event) => {
if (event.target === rename.overlay) {
closeRenamePopup();
}
};
const settings = settingsElements(); const settings = settingsElements();
settings.closeButton.onclick = closeSettings; settings.closeButton.onclick = closeSettings;
settings.generalTab.onclick = () => setSettingsTab("general"); settings.generalTab.onclick = () => setSettingsTab("general");
+15 -2
View File
@@ -68,12 +68,11 @@
<div id="function-bar-meta" class="pathline compact-line">Active:<code id="active-pane-label">left</code></div> <div id="function-bar-meta" class="pathline compact-line">Active:<code id="active-pane-label">left</code></div>
<div id="function-bar" class="toolbar compact-toolbar"> <div id="function-bar" class="toolbar compact-toolbar">
<button id="settings-btn" type="button"><span class="shortcut-hint">F1</span><span>Settings</span></button> <button id="settings-btn" type="button"><span class="shortcut-hint">F1</span><span>Settings</span></button>
<button id="rename-placeholder-btn" type="button" disabled><span class="shortcut-hint">F2</span><span>Rename</span></button> <button id="rename-btn" type="button" disabled><span class="shortcut-hint">F2</span><span>Rename</span></button>
<button id="view-btn" type="button" disabled><span class="shortcut-hint">F3</span><span>View</span></button> <button id="view-btn" type="button" disabled><span class="shortcut-hint">F3</span><span>View</span></button>
<button id="edit-btn" type="button" disabled><span class="shortcut-hint">F4</span><span>Edit</span></button> <button id="edit-btn" type="button" disabled><span class="shortcut-hint">F4</span><span>Edit</span></button>
<button id="copy-btn" type="button" disabled><span class="shortcut-hint">F5</span><span>Copy</span></button> <button id="copy-btn" type="button" disabled><span class="shortcut-hint">F5</span><span>Copy</span></button>
<button id="move-btn" type="button" disabled><span class="shortcut-hint">F6</span><span>Move</span></button> <button id="move-btn" type="button" disabled><span class="shortcut-hint">F6</span><span>Move</span></button>
<button id="rename-btn" type="button" disabled><span>Rename</span></button>
<button id="mkdir-btn" type="button"><span class="shortcut-hint">F7</span><span>MKdir</span></button> <button id="mkdir-btn" type="button"><span class="shortcut-hint">F7</span><span>MKdir</span></button>
<button id="delete-btn" type="button" disabled><span class="shortcut-hint">F8</span><span>Delete</span></button> <button id="delete-btn" type="button" disabled><span class="shortcut-hint">F8</span><span>Delete</span></button>
</div> </div>
@@ -128,6 +127,20 @@
</div> </div>
</div> </div>
<div id="rename-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="rename-title">
<div class="popup-card">
<button id="rename-close-btn" class="viewer-close" type="button" aria-label="Close rename">X</button>
<h3 id="rename-title">Rename</h3>
<label for="rename-input" class="popup-label">Name</label>
<input id="rename-input" type="text" autocomplete="off">
<div id="rename-error" class="error"></div>
<div class="popup-actions">
<button id="rename-apply-btn" type="button">Rename</button>
<button id="rename-cancel-btn" type="button">Cancel</button>
</div>
</div>
</div>
<div id="batch-move-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="batch-move-title"> <div id="batch-move-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="batch-move-title">
<div class="popup-card"> <div class="popup-card">
<h3 id="batch-move-title">Batch Move</h3> <h3 id="batch-move-title">Batch Move</h3>
+11
View File
@@ -490,6 +490,17 @@ button:disabled {
margin-bottom: 6px; margin-bottom: 6px;
} }
#rename-popup .popup-card {
width: min(520px, calc(100vw - 28px));
}
#rename-input {
width: 100%;
margin-top: 4px;
margin-bottom: 6px;
min-width: 0;
}
.popup-actions { .popup-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;