diff --git a/src/internal/common/config_type.go b/src/internal/common/config_type.go index 63580ac65..95a70bdc8 100644 --- a/src/internal/common/config_type.go +++ b/src/internal/common/config_type.go @@ -121,6 +121,11 @@ type HotkeysType struct { PageUp []string `toml:"page_up"` PageDown []string `toml:"page_down"` + // bulk rename + BulkRename []string `toml:"bulk_rename"` + NavBulkRename []string `toml:"nav_bulk_rename"` + RevNavBulkRename []string `toml:"rev_nav_bulk_rename"` + CloseFilePanel []string `toml:"close_file_panel" comment:"file panel control"` CreateNewFilePanel []string `toml:"create_new_file_panel"` NextFilePanel []string `toml:"next_file_panel"` diff --git a/src/internal/common/config_utils.go b/src/internal/common/config_utils.go new file mode 100644 index 000000000..c524839b7 --- /dev/null +++ b/src/internal/common/config_utils.go @@ -0,0 +1,23 @@ +package common + +import ( + "os" + "runtime" + + "github.com/yorukot/superfile/src/internal/utils" +) + +// ResolveEditor returns the command used to open files in an editor. +// Priority: Config.Editor → $EDITOR → OS fallback (non-empty result guaranteed). +func ResolveEditor() string { + if Config.Editor != "" { + return Config.Editor + } + if envEditor := os.Getenv("EDITOR"); envEditor != "" { + return envEditor + } + if runtime.GOOS == utils.OsWindows { + return "notepad" + } + return "nano" +} diff --git a/src/internal/common/style_function.go b/src/internal/common/style_function.go index 41516b2c4..195ed8961 100644 --- a/src/internal/common/style_function.go +++ b/src/internal/common/style_function.go @@ -326,3 +326,18 @@ func GenerateFooterBorder(countString string, width int) string { return strings.Repeat(Config.BorderBottom, repeatCount) + Config.BorderMiddleRight + countString + Config.BorderMiddleLeft } + +func GenerateBulkRenameTextInput(placeholder string) textinput.Model { + ti := textinput.New() + ti.Cursor.Style = ModalStyle + ti.Cursor.TextStyle = ModalStyle + ti.PromptStyle = ModalStyle + ti.Prompt = "" + ti.TextStyle = ModalStyle + ti.Cursor.Blink = true + ti.Placeholder = placeholder + ti.PlaceholderStyle = ModalStyle + ti.CharLimit = 156 + ti.Width = ModalWidth - 10 + return ti +} diff --git a/src/internal/default_config.go b/src/internal/default_config.go index 4155b7292..862fb79bd 100644 --- a/src/internal/default_config.go +++ b/src/internal/default_config.go @@ -5,6 +5,7 @@ import ( zoxidelib "github.com/lazysegtree/go-zoxide" + bulkrename "github.com/yorukot/superfile/src/internal/ui/bulk_rename" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/processbar" "github.com/yorukot/superfile/src/internal/ui/sidebar" @@ -32,15 +33,16 @@ func defaultModelConfig(toggleDotFile, toggleFooter, firstUse bool, filePreview: preview.New(), width: 10, }, - helpMenu: newHelpMenuModal(), - promptModal: prompt.DefaultModel(prompt.PromptMinHeight, prompt.PromptMinWidth), - zoxideModal: zoxideui.DefaultModel(zoxideui.ZoxideMinHeight, zoxideui.ZoxideMinWidth, zClient), - zClient: zClient, - modelQuitState: notQuitting, - toggleDotFile: toggleDotFile, - toggleFooter: toggleFooter, - firstUse: firstUse, - hasTrash: common.InitTrash(), + helpMenu: newHelpMenuModal(), + promptModal: prompt.DefaultModel(prompt.PromptMinHeight, prompt.PromptMinWidth), + zoxideModal: zoxideui.DefaultModel(zoxideui.ZoxideMinHeight, zoxideui.ZoxideMinWidth, zClient), + bulkRenameModel: bulkrename.DefaultModel(bulkrename.DefaultHeight, bulkrename.DefaultWidth), + zClient: zClient, + modelQuitState: notQuitting, + toggleDotFile: toggleDotFile, + toggleFooter: toggleFooter, + firstUse: firstUse, + hasTrash: common.InitTrash(), } } @@ -222,6 +224,11 @@ func getHelpMenuData() []helpMenuModalData { //nolint: funlen // This should be description: "Rename file or folder", hotkeyWorkType: globalType, }, + { + hotkey: common.Hotkeys.BulkRename, + description: "Open bulk rename modal", + hotkeyWorkType: globalType, + }, { hotkey: common.Hotkeys.CopyItems, description: "Copy selected items to the clipboard", diff --git a/src/internal/handle_file_operations.go b/src/internal/handle_file_operations.go index e4fe5ed56..8dfcd3ab0 100644 --- a/src/internal/handle_file_operations.go +++ b/src/internal/handle_file_operations.go @@ -100,6 +100,17 @@ func (m *model) panelItemRename() { panel.rename = common.GenerateRenameTextInput(m.fileModel.width-4, cursorPos, panel.element[panel.cursor].name) } +func (m *model) panelBulkRename() { + panel := &m.fileModel.filePanels[m.filePanelFocusIndex] + + if panel.panelMode != selectMode || len(panel.selected) == 0 { + return + } + + m.bulkRenameModel.Open(panel.selected, panel.location) + m.firstTextInput = true +} + func (m *model) getDeleteCmd(permDelete bool) tea.Cmd { panel := m.getFocusedFilePanel() if len(panel.element) == 0 { @@ -444,19 +455,7 @@ func (m *model) openFileWithEditor() tea.Cmd { slog.Error("Error while writing to chooser file, continuing with open via file editor", "error", err) } - editor := common.Config.Editor - if editor == "" { - editor = os.Getenv("EDITOR") - } - - // Make sure there is an editor - if editor == "" { - if runtime.GOOS == utils.OsWindows { - editor = "notepad" - } else { - editor = "nano" - } - } + editor := common.ResolveEditor() // Split the editor command into command and arguments parts := strings.Fields(editor) diff --git a/src/internal/key_function.go b/src/internal/key_function.go index 1a36e6090..51af8a8a9 100644 --- a/src/internal/key_function.go +++ b/src/internal/key_function.go @@ -130,42 +130,71 @@ func (m *model) mainKey(msg string) tea.Cmd { //nolint: gocyclo,cyclop,funlen // } func (m *model) normalAndBrowserModeKey(msg string) tea.Cmd { - // if not focus on the filepanel return if !m.getFocusedFilePanel().isFocused { - if m.focusPanel == sidebarFocus && slices.Contains(common.Hotkeys.Confirm, msg) { - m.sidebarSelectDirectory() - } - if m.focusPanel == sidebarFocus && slices.Contains(common.Hotkeys.FilePanelItemRename, msg) { - m.sidebarModel.PinnedItemRename() - } - if m.focusPanel == sidebarFocus && slices.Contains(common.Hotkeys.SearchBar, msg) { - m.sidebarSearchBarFocus() - } - return nil + return m.handleSidebarFocusKeys(msg) } - // Check if in the select mode and focusOn filepanel + if m.getFocusedFilePanel().panelMode == selectMode { - switch { - case slices.Contains(common.Hotkeys.Confirm, msg): - m.fileModel.filePanels[m.filePanelFocusIndex].singleItemSelect() - case slices.Contains(common.Hotkeys.FilePanelSelectModeItemsSelectUp, msg): - m.fileModel.filePanels[m.filePanelFocusIndex].itemSelectUp(m.mainPanelHeight) - case slices.Contains(common.Hotkeys.FilePanelSelectModeItemsSelectDown, msg): - m.fileModel.filePanels[m.filePanelFocusIndex].itemSelectDown(m.mainPanelHeight) - case slices.Contains(common.Hotkeys.DeleteItems, msg): - return m.getDeleteTriggerCmd(false) - case slices.Contains(common.Hotkeys.PermanentlyDeleteItems, msg): - return m.getDeleteTriggerCmd(true) - case slices.Contains(common.Hotkeys.CopyItems, msg): - m.copyMultipleItem(false) - case slices.Contains(common.Hotkeys.CutItems, msg): - m.copyMultipleItem(true) - case slices.Contains(common.Hotkeys.FilePanelSelectAllItem, msg): - m.selectAllItem() - } + return m.handleSelectModeKeys(msg) + } + + return m.handleBrowserModeKeys(msg) +} + +func (m *model) handleSidebarFocusKeys(msg string) tea.Cmd { + if m.focusPanel != sidebarFocus { return nil } + switch { + case slices.Contains(common.Hotkeys.Confirm, msg): + m.sidebarSelectDirectory() + case slices.Contains(common.Hotkeys.FilePanelItemRename, msg): + m.sidebarModel.PinnedItemRename() + case slices.Contains(common.Hotkeys.SearchBar, msg): + m.sidebarSearchBarFocus() + } + return nil +} + +func (m *model) handleSelectModeKeys(msg string) tea.Cmd { + if slices.Contains(common.Hotkeys.Confirm, msg) { + m.fileModel.filePanels[m.filePanelFocusIndex].singleItemSelect() + return nil + } + if slices.Contains(common.Hotkeys.FilePanelSelectModeItemsSelectUp, msg) { + m.fileModel.filePanels[m.filePanelFocusIndex].itemSelectUp(m.mainPanelHeight) + return nil + } + if slices.Contains(common.Hotkeys.FilePanelSelectModeItemsSelectDown, msg) { + m.fileModel.filePanels[m.filePanelFocusIndex].itemSelectDown(m.mainPanelHeight) + return nil + } + if slices.Contains(common.Hotkeys.DeleteItems, msg) { + return m.getDeleteTriggerCmd(false) + } + if slices.Contains(common.Hotkeys.PermanentlyDeleteItems, msg) { + return m.getDeleteTriggerCmd(true) + } + + return m.handleSelectModeFileOperations(msg) +} + +func (m *model) handleSelectModeFileOperations(msg string) tea.Cmd { + switch { + case slices.Contains(common.Hotkeys.CopyItems, msg): + m.copyMultipleItem(false) + case slices.Contains(common.Hotkeys.CutItems, msg): + m.copyMultipleItem(true) + case slices.Contains(common.Hotkeys.BulkRename, msg): + m.panelBulkRename() + case slices.Contains(common.Hotkeys.FilePanelSelectAllItem, msg): + m.selectAllItem() + } + return nil +} + +func (m *model) handleBrowserModeKeys(msg string) tea.Cmd { switch { case slices.Contains(common.Hotkeys.Confirm, msg): m.enterPanel() @@ -175,6 +204,14 @@ func (m *model) normalAndBrowserModeKey(msg string) tea.Cmd { return m.getDeleteTriggerCmd(false) case slices.Contains(common.Hotkeys.PermanentlyDeleteItems, msg): return m.getDeleteTriggerCmd(true) + default: + return m.handleBrowserModeFileOperations(msg) + } + return nil +} + +func (m *model) handleBrowserModeFileOperations(msg string) tea.Cmd { + switch { case slices.Contains(common.Hotkeys.CopyItems, msg): m.copySingleItem(false) case slices.Contains(common.Hotkeys.CutItems, msg): diff --git a/src/internal/model.go b/src/internal/model.go index 6aba3f704..986487d14 100644 --- a/src/internal/model.go +++ b/src/internal/model.go @@ -4,6 +4,7 @@ import ( "errors" "log/slog" "os" + "os/exec" "path/filepath" "reflect" "slices" @@ -12,6 +13,7 @@ import ( "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" + bulkrename "github.com/yorukot/superfile/src/internal/ui/bulk_rename" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/notify" "github.com/yorukot/superfile/src/internal/utils" @@ -87,6 +89,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: inputCmd = m.handleKeyInput(msg) + case editorFinishedForBulkRenameMsg: + updateCmd = m.handleEditorFinishedForBulkRename(msg.err) + // Has to handle zoxide messages separately as they could be generated via // zoxide update commands, or batched commands from textinput // Cannot do it like processbar messages @@ -355,57 +360,85 @@ func (m *model) handleKeyInput(msg tea.KeyMsg) tea.Cmd { m.firstUse = false return nil } - var cmd tea.Cmd - cdOnQuit := common.Config.CdOnQuit - switch { - case m.typingModal.open: - m.typingModalOpenKey(msg.String()) - case m.promptModal.IsOpen(): - // Ignore keypress. It will be handled in Update call via - // updateFilePanelState - // TODO: Convert that to async via tea.Cmd - case m.zoxideModal.IsOpen(): - // Ignore keypress. It will be handled in Update call via - // updateFilePanelState - // Handles all warn models except the warn model for confirming to quit - case m.notifyModel.IsOpen(): - cmd = m.notifyModelOpenKey(msg.String()) + cmd := m.handleModalOrDefaultKey(msg) + return m.handleQuitState(cmd, common.Config.CdOnQuit) +} - // If renaming a object - case m.fileModel.renaming: - cmd = m.renamingKey(msg.String()) - case m.sidebarModel.IsRenaming(): - m.sidebarRenamingKey(msg.String()) - // If search bar is open - case m.fileModel.filePanels[m.filePanelFocusIndex].searchBar.Focused(): - m.focusOnSearchbarKey(msg.String()) - // If sort options menu is open - case m.sidebarModel.SearchBarFocused(): - m.sidebarModel.HandleSearchBarKey(msg.String()) - case m.fileModel.filePanels[m.filePanelFocusIndex].sortOptions.open: - m.sortOptionsKey(msg.String()) - // If help menu is open - case m.helpMenu.open: - m.helpMenuKey(msg.String()) - - case slices.Contains(common.Hotkeys.Quit, msg.String()): - m.modelQuitState = quitInitiated +func (m *model) handleModalOrDefaultKey(msg tea.KeyMsg) tea.Cmd { + if cmd := m.routeModalInput(msg.String()); cmd != nil { + return cmd + } + if m.isAnyModalActive() { + return nil + } + return m.handleDefaultKey(msg.String()) +} - case slices.Contains(common.Hotkeys.CdQuit, msg.String()): - m.modelQuitState = quitInitiated - cdOnQuit = true +func (m *model) routeModalInput(msg string) tea.Cmd { + if m.typingModal.open { + m.typingModalOpenKey(msg) + return nil + } + if m.promptModal.IsOpen() || m.zoxideModal.IsOpen() || m.bulkRenameModel.IsOpen() { + return nil + } + if m.notifyModel.IsOpen() { + return m.notifyModelOpenKey(msg) + } + if m.fileModel.renaming { + return m.renamingKey(msg) + } + if m.sidebarModel.IsRenaming() { + m.sidebarRenamingKey(msg) + return nil + } + panel := m.fileModel.filePanels[m.filePanelFocusIndex] + if panel.searchBar.Focused() { + m.focusOnSearchbarKey(msg) + return nil + } + if m.sidebarModel.SearchBarFocused() { + m.sidebarModel.HandleSearchBarKey(msg) + return nil + } + if panel.sortOptions.open { + m.sortOptionsKey(msg) + return nil + } + if m.helpMenu.open { + m.helpMenuKey(msg) + return nil + } + return nil +} + +func (m *model) isAnyModalActive() bool { + panel := m.fileModel.filePanels[m.filePanelFocusIndex] + return m.typingModal.open || m.promptModal.IsOpen() || m.zoxideModal.IsOpen() || + m.notifyModel.IsOpen() || m.bulkRenameModel.IsOpen() || m.fileModel.renaming || + m.sidebarModel.IsRenaming() || panel.searchBar.Focused() || + m.sidebarModel.SearchBarFocused() || panel.sortOptions.open || m.helpMenu.open +} + +func (m *model) handleDefaultKey(msg string) tea.Cmd { + switch { + case slices.Contains(common.Hotkeys.Quit, msg): + m.modelQuitState = quitInitiated + return nil + case slices.Contains(common.Hotkeys.CdQuit, msg): + m.modelQuitState = quitInitiated + common.Config.CdOnQuit = true + return nil default: - // Handles general kinds of inputs in the regular state of the application - cmd = m.mainKey(msg.String()) + return m.mainKey(msg) } +} - // If quiting input pressed, check if has any running process and displays a - // warn. Otherwise just quits application +func (m *model) handleQuitState(cmd tea.Cmd, cdOnQuit bool) tea.Cmd { if m.modelQuitState == quitInitiated { if m.processBarModel.HasRunningProcesses() { - // Dont quit now, get a confirmation first. m.modelQuitState = quitConfirmationInitiated m.warnModalForQuit() return cmd @@ -419,15 +452,36 @@ func (m *model) handleKeyInput(msg tea.KeyMsg) tea.Cmd { return cmd } -// Update the file panel state. Change name of renamed files, filter out files -// in search, update typingb bar, etc func (m *model) updateFilePanelsState(msg tea.Msg) tea.Cmd { focusPanel := &m.fileModel.filePanels[m.filePanelFocusIndex] var cmd tea.Cmd + + if m.firstTextInput { + m.firstTextInput = false + return nil + } + + cmd = m.handleInputUpdates(msg, focusPanel) + + // The code should never reach this state. + if focusPanel.cursor < 0 { + focusPanel.cursor = 0 + } + + return cmd +} + +func (m *model) handleInputUpdates(msg tea.Msg, focusPanel *filePanel) tea.Cmd { + var cmd tea.Cmd var action common.ModelAction + switch { - case m.firstTextInput: - m.firstTextInput = false + case m.bulkRenameModel.IsOpen(): + action, cmd = m.bulkRenameModel.HandleUpdate(msg) + editorCmd := m.applyBulkRenameAction(action) + if editorCmd != nil { + cmd = tea.Batch(cmd, editorCmd) + } case m.fileModel.renaming: focusPanel.rename, cmd = focusPanel.rename.Update(msg) case focusPanel.searchBar.Focused(): @@ -435,7 +489,6 @@ func (m *model) updateFilePanelsState(msg tea.Msg) tea.Cmd { case m.typingModal.open: m.typingModal.textInput, cmd = m.typingModal.textInput.Update(msg) case m.promptModal.IsOpen(): - // TODO : Separate this to a utility cwdLocation := m.fileModel.filePanels[m.filePanelFocusIndex].location action, cmd = m.promptModal.HandleUpdate(msg, cwdLocation) m.applyPromptModalAction(action) @@ -444,12 +497,6 @@ func (m *model) updateFilePanelsState(msg tea.Msg) tea.Cmd { m.applyZoxideModalAction(action) } - // TODO : This is like duct taping a bigger problem - // The code should never reach this state. - if focusPanel.cursor < 0 { - focusPanel.cursor = 0 - } - return cmd } @@ -493,6 +540,131 @@ func (m *model) applyZoxideModalAction(action common.ModelAction) { _, _ = m.logAndExecuteAction(action) } +func (m *model) applyBulkRenameAction(action common.ModelAction) tea.Cmd { + if editorAction, ok := action.(bulkrename.EditorModeAction); ok { + return m.handleEditorModeAction(editorAction) + } + if brAction, ok := action.(bulkrename.BulkRenameAction); ok { + return bulkrename.ExecuteBulkRename(&m.processBarModel, brAction.Previews) + } + _, _ = m.logAndExecuteAction(action) + return nil +} + +func (m *model) handleEditorModeAction(action bulkrename.EditorModeAction) tea.Cmd { + m.pendingEditorAction = &action + + parts := strings.Fields(action.Editor) + cmd := parts[0] + args := parts[1:] + args = append(args, action.TmpfilePath) + + c := exec.Command(cmd, args...) + + return tea.ExecProcess(c, func(err error) tea.Msg { + return editorFinishedForBulkRenameMsg{err} + }) +} + +func (m *model) handleEditorFinishedForBulkRename(err error) tea.Cmd { + if m.pendingEditorAction == nil { + slog.Error("No pending editor action found") + return nil + } + + action := m.pendingEditorAction + defer func() { + os.Remove(action.TmpfilePath) + m.pendingEditorAction = nil + }() + + if err != nil { + slog.Error("Editor finished with error", "error", err) + return nil + } + + lines, readErr := m.readEditorOutputLines(action.TmpfilePath) + if readErr != nil { + return nil + } + + if len(lines) != len(action.SelectedFiles) { + slog.Error("Number of lines in tmpfile doesn't match number of selected files", + "expected", len(action.SelectedFiles), "got", len(lines)) + return nil + } + + previews := m.buildRenamePreviews(action.SelectedFiles, lines) + validPreviews := m.filterValidPreviews(previews) + + if len(validPreviews) == 0 { + slog.Info("No valid renames to apply from tmpfile") + m.bulkRenameModel.Close() + return nil + } + + m.bulkRenameModel.Close() + return bulkrename.ExecuteBulkRename(&m.processBarModel, validPreviews) +} + +func (m *model) readEditorOutputLines(tmpfilePath string) ([]string, error) { + content, err := os.ReadFile(tmpfilePath) + if err != nil { + slog.Error("Failed to read tmpfile", "error", err) + return nil, err + } + return strings.Split(strings.TrimSpace(string(content)), "\n"), nil +} + +func (m *model) buildRenamePreviews(selectedFiles []string, newNames []string) []bulkrename.RenamePreview { + previews := make([]bulkrename.RenamePreview, 0, len(newNames)) + for i, itemPath := range selectedFiles { + oldName := filepath.Base(itemPath) + newName := strings.TrimSpace(newNames[i]) + + if newName == "" { + slog.Warn("Empty filename in tmpfile, skipping", "line", i+1) + continue + } + + preview := bulkrename.RenamePreview{ + OldPath: itemPath, + OldName: oldName, + NewName: newName, + } + + preview.Error = m.validateRename(itemPath, oldName, newName) + previews = append(previews, preview) + } + return previews +} + +func (m *model) validateRename(itemPath, oldName, newName string) string { + if newName == oldName { + return "No change" + } + + newPath := filepath.Join(filepath.Dir(itemPath), newName) + if strings.EqualFold(itemPath, newPath) { + return "" + } + + if _, err := os.Stat(newPath); err == nil { + return "File already exists" + } + return "" +} + +func (m *model) filterValidPreviews(previews []bulkrename.RenamePreview) []bulkrename.RenamePreview { + validPreviews := make([]bulkrename.RenamePreview, 0, len(previews)) + for _, p := range previews { + if p.Error == "" { + validPreviews = append(validPreviews, p) + } + } + return validPreviews +} + // TODO : Move them around to appropriate places func (m *model) applyShellCommandAction(shellCommand string) { focusPanelDir := m.fileModel.filePanels[m.filePanelFocusIndex].location @@ -656,6 +828,13 @@ func (m *model) updateRenderForOverlay(finalRender string) string { return stringfunction.PlaceOverlay(overlayX, overlayY, typingModal, finalRender) } + if m.bulkRenameModel.IsOpen() { + bulkRenameModal := m.bulkRenameModel.Render() + overlayX := m.fullWidth/2 - m.bulkRenameModel.GetWidth()/2 + overlayY := m.fullHeight/2 - m.bulkRenameModel.GetHeight()/2 + return stringfunction.PlaceOverlay(overlayX, overlayY, bulkRenameModal, finalRender) + } + if m.notifyModel.IsOpen() { notifyModal := m.notifyModel.Render() overlayX := m.fullWidth/2 - common.ModalWidth/2 diff --git a/src/internal/type.go b/src/internal/type.go index b0902b1e1..877fcc03b 100644 --- a/src/internal/type.go +++ b/src/internal/type.go @@ -5,6 +5,7 @@ import ( zoxidelib "github.com/lazysegtree/go-zoxide" + bulkrename "github.com/yorukot/superfile/src/internal/ui/bulk_rename" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/notify" "github.com/yorukot/superfile/src/internal/ui/processbar" @@ -72,11 +73,12 @@ type model struct { copyItems copyItems // Modals - notifyModel notify.Model - typingModal typingModal - helpMenu helpMenuModal - promptModal prompt.Model - zoxideModal zoxideui.Model + notifyModel notify.Model + typingModal typingModal + bulkRenameModel bulkrename.Model + helpMenu helpMenuModal + promptModal prompt.Model + zoxideModal zoxideui.Model // Zoxide client for directory tracking zClient *zoxidelib.Client @@ -91,6 +93,9 @@ type model struct { firstLoadingComplete bool firstUse bool + // Pending editor action for bulk rename + pendingEditorAction *bulkrename.EditorModeAction + // This entirely disables metadata fetching. Used in test model disableMetadata bool filePanelFocusIndex int @@ -200,5 +205,6 @@ type element struct { /* FILE WINDOWS TYPE END*/ type editorFinishedMsg struct{ err error } +type editorFinishedForBulkRenameMsg struct{ err error } type sliceOrderFunc func(i, j int) bool diff --git a/src/internal/ui/bulk_rename/model.go b/src/internal/ui/bulk_rename/model.go new file mode 100644 index 000000000..a67b4f8b6 --- /dev/null +++ b/src/internal/ui/bulk_rename/model.go @@ -0,0 +1,456 @@ +package bulkrename + +import ( + "log/slog" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/yorukot/superfile/src/config/icon" + "github.com/yorukot/superfile/src/internal/common" + "github.com/yorukot/superfile/src/internal/ui/processbar" +) + +const ( + FindReplace RenameType = iota + AddPrefix + AddSuffix + AddNumbering + ChangeCase + EditorMode +) + +const ( + CaseLower CaseType = iota + CaseUpper + CaseTitle +) + +const ( + DefaultHeight = 25 + DefaultWidth = 80 +) + +func (msg UpdateMsg) GetReqID() int { + return msg.reqID +} + +func DefaultModel(maxHeight int, width int) Model { + findInput := common.GenerateBulkRenameTextInput("Find text") + replaceInput := common.GenerateBulkRenameTextInput("Replace with") + prefixInput := common.GenerateBulkRenameTextInput("Add prefix") + suffixInput := common.GenerateBulkRenameTextInput("Add suffix") + + return Model{ + open: false, + renameType: FindReplace, + caseType: CaseLower, + cursor: 0, + startNumber: 1, + width: width, + height: maxHeight, + reqCnt: 0, + findInput: findInput, + replaceInput: replaceInput, + prefixInput: prefixInput, + suffixInput: suffixInput, + } +} + +func (m *Model) IsOpen() bool { + return m.open +} + +func (m *Model) Open(selectedFiles []string, currentDir string) { + if len(selectedFiles) == 0 { + return + } + + m.open = true + m.selectedFiles = selectedFiles + m.currentDir = currentDir + m.renameType = FindReplace + m.caseType = CaseLower + m.cursor = 0 + m.startNumber = 1 + m.errorMessage = "" + m.preview = nil + + m.findInput.Reset() + m.replaceInput.Reset() + m.prefixInput.Reset() + m.suffixInput.Reset() + + m.focusInput() +} + +func (m *Model) Close() { + m.open = false + m.selectedFiles = nil + m.currentDir = "" + m.errorMessage = "" + m.preview = nil + + m.findInput.Blur() + m.replaceInput.Blur() + m.prefixInput.Blur() + m.suffixInput.Blur() +} + +func (m *Model) HandleUpdate(msg tea.Msg) (common.ModelAction, tea.Cmd) { + slog.Debug("bulk_rename.Model HandleUpdate()", "msg", msg) + var action common.ModelAction = common.NoAction{} + var cmd tea.Cmd + + if !m.IsOpen() { + slog.Error("HandleUpdate called on closed bulk rename modal") + return action, cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case slices.Contains(common.Hotkeys.CancelTyping, msg.String()): + m.Close() + case slices.Contains(common.Hotkeys.ConfirmTyping, msg.String()): + action = m.handleConfirm() + case slices.Contains(common.Hotkeys.ListUp, msg.String()): + m.adjustValue(-1) + case slices.Contains(common.Hotkeys.ListDown, msg.String()): + m.adjustValue(1) + case slices.Contains(common.Hotkeys.NavBulkRename, msg.String()): + m.nextType() + case slices.Contains(common.Hotkeys.RevNavBulkRename, msg.String()): + m.prevType() + default: + cmd = m.handleTextInputUpdate(msg) + } + default: + cmd = m.handleTextInputUpdate(msg) + } + + return action, cmd +} + +func (m *Model) handleConfirm() common.ModelAction { + if m.renameType == EditorMode { + return m.handleEditorMode() + } + + previews := m.GeneratePreviewWithValidation() + + validPreviews := make([]RenamePreview, 0, len(previews)) + for _, p := range previews { + if p.Error == "" { + validPreviews = append(validPreviews, p) + } + } + + if len(validPreviews) == 0 { + m.errorMessage = "No valid renames to apply" + return common.NoAction{} + } + + m.Close() + return BulkRenameAction{ + Previews: validPreviews, + } +} + +func (m *Model) handleEditorMode() common.ModelAction { + editor := common.ResolveEditor() + + tmpfile, err := os.CreateTemp("", "superfile-bulk-rename-*.txt") + if err != nil { + m.errorMessage = "Failed to create temporary file: " + err.Error() + return common.NoAction{} + } + tmpfilePath := tmpfile.Name() + + for _, itemPath := range m.selectedFiles { + filename := filepath.Base(itemPath) + _, err := tmpfile.WriteString(filename + "\n") + if err != nil { + tmpfile.Close() + os.Remove(tmpfilePath) + m.errorMessage = "Failed to write to temporary file: " + err.Error() + return common.NoAction{} + } + } + tmpfile.Close() + + return EditorModeAction{ + TmpfilePath: tmpfilePath, + Editor: editor, + SelectedFiles: m.selectedFiles, + CurrentDir: m.currentDir, + } +} + +func (a EditorModeAction) String() string { + return "EditorModeAction with editor: " + a.Editor +} + +func (a BulkRenameAction) String() string { + return "BulkRenameAction with " + strconv.Itoa(len(a.Previews)) + " items" +} + +func (m *Model) handleTextInputUpdate(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + + switch m.renameType { + case FindReplace: + if m.cursor == 0 { + m.findInput, cmd = m.findInput.Update(msg) + } else { + m.replaceInput, cmd = m.replaceInput.Update(msg) + } + case AddPrefix: + m.prefixInput, cmd = m.prefixInput.Update(msg) + case AddSuffix: + m.suffixInput, cmd = m.suffixInput.Update(msg) + } + + m.preview = nil + + return cmd +} + +func (m *Model) adjustValue(delta int) { + switch m.renameType { + case FindReplace: + m.navigateCursor(delta) + case AddNumbering: + newValue := m.startNumber + delta + if newValue >= 0 { + m.startNumber = newValue + m.preview = nil + } + case ChangeCase: + newValue := int(m.caseType) + delta + if newValue >= 0 && newValue <= 2 { + m.caseType = CaseType(newValue) + m.preview = nil + } + } +} +func (m *Model) GeneratePreview() []RenamePreview { + return m.generatePreview(false) +} + +func (m *Model) GeneratePreviewWithValidation() []RenamePreview { + return m.generatePreview(true) +} + +func (m *Model) generatePreview(validate bool) []RenamePreview { + previews := make([]RenamePreview, 0, len(m.selectedFiles)) + + for i, itemPath := range m.selectedFiles { + preview := m.createRenamePreview(itemPath, i, validate) + previews = append(previews, preview) + } + + m.preview = previews + return previews +} + +func (m *Model) createRenamePreview(itemPath string, index int, validate bool) RenamePreview { + oldName := filepath.Base(itemPath) + newName := m.applyTransformation(oldName, index) + + var err string + if validate { + err = m.validateRename(itemPath, oldName, newName) + } else { + err = m.validateRenameWithoutStat(oldName, newName) + } + + return RenamePreview{ + OldPath: itemPath, + OldName: oldName, + NewName: newName, + Error: err, + } +} + +func (m *Model) applyTransformation(oldName string, index int) string { + switch m.renameType { + case FindReplace: + return m.applyFindReplace(oldName) + case AddPrefix: + return m.applyPrefix(oldName) + case AddSuffix: + return m.applySuffix(oldName) + case AddNumbering: + return m.applyNumbering(oldName, index) + case ChangeCase: + return m.applyCaseConversion(oldName) + case EditorMode: + // EditorMode doesn't use real-time transformation + return oldName + default: + return oldName + } +} + +func (m *Model) applyFindReplace(filename string) string { + find := m.findInput.Value() + replace := m.replaceInput.Value() + if find == "" { + return filename + } + return strings.ReplaceAll(filename, find, replace) +} + +func (m *Model) applyPrefix(filename string) string { + prefix := m.prefixInput.Value() + if prefix == "" { + return filename + } + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + return prefix + nameWithoutExt + ext +} + +func (m *Model) applySuffix(filename string) string { + suffix := m.suffixInput.Value() + if suffix == "" { + return filename + } + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + return nameWithoutExt + suffix + ext +} + +func (m *Model) applyNumbering(filename string, number int) string { + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + return nameWithoutExt + "_" + strconv.Itoa(m.startNumber+number) + ext +} + +func (m *Model) applyCaseConversion(filename string) string { + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + + var converted string + switch m.caseType { + case CaseLower: + converted = strings.ToLower(nameWithoutExt) + case CaseUpper: + converted = strings.ToUpper(nameWithoutExt) + case CaseTitle: + converted = toTitleCase(nameWithoutExt) + default: + converted = nameWithoutExt + } + + return converted + ext +} + +func toTitleCase(text string) string { + words := strings.Fields(strings.ToLower(text)) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(string(word[0])) + word[1:] + } + } + return strings.Join(words, " ") +} + +func (m *Model) validateRenameWithoutStat(oldName, newName string) string { + if newName == "" { + return "Empty filename" + } + if newName == oldName { + return "No change" + } + return "" +} + +func (m *Model) validateRename(itemPath, oldName, newName string) string { + if newName == "" { + return "Empty filename" + } + if newName == oldName { + return "No change" + } + + newPath := filepath.Join(filepath.Dir(itemPath), newName) + + if strings.EqualFold(itemPath, newPath) { + return "" + } + + if _, statErr := os.Stat(newPath); statErr == nil { + return "File already exists" + } + return "" +} +func (m *Model) GetWidth() int { + return 80 +} + +func (m *Model) GetHeight() int { + return 25 +} +func ExecuteBulkRename(processBarModel *processbar.Model, previews []RenamePreview) tea.Cmd { + return func() tea.Msg { + state := bulkRenameOperation(processBarModel, previews) + return NewBulkRenameResultMsg(state, len(previews)) + } +} + +func bulkRenameOperation(processBarModel *processbar.Model, previews []RenamePreview) processbar.ProcessState { + if len(previews) == 0 { + return processbar.Cancelled + } + + p, err := processBarModel.SendAddProcessMsg(icon.Terminal+icon.Space+"Bulk Rename", len(previews), true) + if err != nil { + slog.Error("Cannot spawn bulk rename process", "error", err) + return processbar.Failed + } + + for _, preview := range previews { + newPath := filepath.Join(filepath.Dir(preview.OldPath), preview.NewName) + + err = os.Rename(preview.OldPath, newPath) + if err != nil { + p.State = processbar.Failed + slog.Error("Error in bulk rename operation", "old", preview.OldPath, "new", newPath, "error", err) + break + } + + p.Name = icon.Terminal + icon.Space + preview.NewName + p.Done++ + processBarModel.TrySendingUpdateProcessMsg(p) + } + + if p.State != processbar.Failed { + p.State = processbar.Successful + processBarModel.TrySendingUpdateProcessMsg(p) + } + + return p.State +} + +func NewBulkRenameResultMsg(state processbar.ProcessState, count int) BulkRenameResultMsg { + return BulkRenameResultMsg{state: state, count: count} +} + +func (msg BulkRenameResultMsg) GetState() processbar.ProcessState { + return msg.state +} + +func (msg BulkRenameResultMsg) GetCount() int { + return msg.count +} + +func GetCursorColor() lipgloss.Color { + return lipgloss.Color(common.Theme.Cursor) +} diff --git a/src/internal/ui/bulk_rename/model_test.go b/src/internal/ui/bulk_rename/model_test.go new file mode 100644 index 000000000..dda685024 --- /dev/null +++ b/src/internal/ui/bulk_rename/model_test.go @@ -0,0 +1,312 @@ +package bulkrename + +import ( + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yorukot/superfile/src/internal/common" + "github.com/yorukot/superfile/src/internal/ui/processbar" +) + +func init() { + common.Theme.GradientColor = []string{"#FF0000", "#00FF00"} +} + +func setupTestFiles(t *testing.T, dir string, filenames []string) []string { + t.Helper() + var paths []string + for _, name := range filenames { + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte("test content"), 0644)) + paths = append(paths, path) + } + return paths +} + +func withProcessBar(t *testing.T, fn func(*processbar.Model)) { + t.Helper() + pb := processbar.NewModelWithOptions(80, 25) + pb.ListenForChannelUpdates() + defer pb.SendStopListeningMsgBlocking() + fn(&pb) +} + +func TestApplyFindReplace(t *testing.T) { + m := DefaultModel(25, 80) + + tests := []struct { + find, replace, input, want string + }{ + {"old", "new", "old_file.txt", "new_file.txt"}, + {"test", "demo", "test_test_file.txt", "demo_demo_file.txt"}, + {"", "new", "file.txt", "file.txt"}, + {"old_", "", "old_file.txt", "file.txt"}, + {"xyz", "abc", "file.txt", "file.txt"}, + } + + for _, tt := range tests { + m.findInput.SetValue(tt.find) + m.replaceInput.SetValue(tt.replace) + assert.Equal(t, tt.want, m.applyFindReplace(tt.input)) + } +} + +func TestApplyPrefix(t *testing.T) { + m := DefaultModel(25, 80) + + tests := []struct { + prefix, input, want string + }{ + {"new_", "file.txt", "new_file.txt"}, + {"test_", "document.pdf", "test_document.pdf"}, + {"prefix_", "file", "prefix_file"}, + {"", "file.txt", "file.txt"}, + {"new_", ".gitignore", "new_.gitignore"}, + } + + for _, tt := range tests { + m.prefixInput.SetValue(tt.prefix) + assert.Equal(t, tt.want, m.applyPrefix(tt.input)) + } +} + +func TestApplySuffix(t *testing.T) { + m := DefaultModel(25, 80) + + tests := []struct { + suffix, input, want string + }{ + {"_copy", "file.txt", "file_copy.txt"}, + {"_backup", "document.pdf", "document_backup.pdf"}, + {"_new", "file", "file_new"}, + {"", "file.txt", "file.txt"}, + } + + for _, tt := range tests { + m.suffixInput.SetValue(tt.suffix) + assert.Equal(t, tt.want, m.applySuffix(tt.input)) + } +} + +func TestApplyNumbering(t *testing.T) { + m := DefaultModel(25, 80) + m.startNumber = 1 + + tests := []struct { + input string + idx int + want string + }{ + {"file.txt", 0, "file_1.txt"}, + {"file.txt", 1, "file_2.txt"}, + {"file", 0, "file_1"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, m.applyNumbering(tt.input, tt.idx)) + } +} + +func TestApplyCaseConversion(t *testing.T) { + m := DefaultModel(25, 80) + + tests := []struct { + caseType CaseType + input string + want string + }{ + {CaseLower, "MyFile.TXT", "myfile.TXT"}, + {CaseUpper, "myfile.txt", "MYFILE.txt"}, + {CaseTitle, "my document file.pdf", "My Document File.pdf"}, + {CaseTitle, "file.txt", "File.txt"}, + } + + for _, tt := range tests { + m.caseType = tt.caseType + assert.Equal(t, tt.want, m.applyCaseConversion(tt.input)) + } +} + +func TestValidateRename(t *testing.T) { + tmpDir := t.TempDir() + setupTestFiles(t, tmpDir, []string{"existing.txt", "test.txt"}) + testFile := filepath.Join(tmpDir, "test.txt") + + m := DefaultModel(25, 80) + + tests := []struct { + old, new, wantErr string + }{ + {"test.txt", "newname.txt", ""}, + {"test.txt", "", "Empty filename"}, + {"test.txt", "test.txt", "No change"}, + {"test.txt", "existing.txt", "File already exists"}, + {"test.txt", "TEST.txt", ""}, + } + + for _, tt := range tests { + assert.Equal(t, tt.wantErr, m.validateRename(testFile, tt.old, tt.new)) + } +} + +func TestGeneratePreview(t *testing.T) { + tmpDir := t.TempDir() + files := setupTestFiles(t, tmpDir, []string{"file1.txt", "file2.txt", "file3.txt"}) + + m := DefaultModel(25, 80) + m.selectedFiles = files + m.currentDir = tmpDir + m.renameType = AddPrefix + m.prefixInput.SetValue("new_") + + previews := m.GeneratePreview() + + require.Len(t, previews, 3) + for i, p := range previews { + assert.Equal(t, filepath.Base(files[i]), p.OldName) + assert.Equal(t, "new_"+filepath.Base(files[i]), p.NewName) + assert.Empty(t, p.Error) + } +} + +func TestOpenAndClose(t *testing.T) { + tmpDir := t.TempDir() + files := setupTestFiles(t, tmpDir, []string{"file1.txt", "file2.txt"}) + + m := DefaultModel(25, 80) + assert.False(t, m.IsOpen()) + + m.Open(files, tmpDir) + assert.True(t, m.IsOpen()) + assert.Equal(t, files, m.selectedFiles) + assert.Equal(t, tmpDir, m.currentDir) + assert.Equal(t, FindReplace, m.renameType) + + m.Close() + assert.False(t, m.IsOpen()) + assert.Nil(t, m.selectedFiles) + assert.Empty(t, m.currentDir) +} + +func TestNavigateTypes(t *testing.T) { + tmpDir := t.TempDir() + files := setupTestFiles(t, tmpDir, []string{"file1.txt"}) + + m := DefaultModel(25, 80) + m.Open(files, tmpDir) + + types := []RenameType{FindReplace, AddPrefix, AddSuffix, AddNumbering, ChangeCase, EditorMode} + + for i := 0; i < len(types); i++ { + assert.Equal(t, types[i], m.renameType) + m.nextType() + } + assert.Equal(t, FindReplace, m.renameType) + + m.prevType() + assert.Equal(t, EditorMode, m.renameType) + m.prevType() + assert.Equal(t, ChangeCase, m.renameType) +} + +func TestAdjustValue(t *testing.T) { + m := DefaultModel(25, 80) + + m.renameType = AddNumbering + m.startNumber = 5 + m.adjustValue(1) + assert.Equal(t, 6, m.startNumber) + m.adjustValue(-2) + assert.Equal(t, 4, m.startNumber) + + m.startNumber = 0 + m.adjustValue(-1) + assert.Equal(t, 0, m.startNumber) + + m.renameType = ChangeCase + m.caseType = CaseLower + m.adjustValue(1) + assert.Equal(t, CaseUpper, m.caseType) + m.adjustValue(1) + assert.Equal(t, CaseTitle, m.caseType) + m.adjustValue(1) + assert.Equal(t, CaseTitle, m.caseType) +} + +func TestBulkRenameOperation(t *testing.T) { + t.Run("successful rename", func(t *testing.T) { + tmpDir := t.TempDir() + files := map[string]string{"old1.txt": "content1", "old2.txt": "content2"} + + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0644)) + } + + previews := []RenamePreview{ + {filepath.Join(tmpDir, "old1.txt"), "old1.txt", "new1.txt", ""}, + {filepath.Join(tmpDir, "old2.txt"), "old2.txt", "new2.txt", ""}, + } + + withProcessBar(t, func(pb *processbar.Model) { + state := bulkRenameOperation(pb, previews) + assert.Equal(t, processbar.Successful, state) + + for old, content := range files { + newPath := filepath.Join(tmpDir, "new"+old[3:]) + assert.FileExists(t, newPath) + data, _ := os.ReadFile(newPath) + assert.Equal(t, content, string(data)) + assert.NoFileExists(t, filepath.Join(tmpDir, old)) + } + }) + }) + + t.Run("empty previews returns cancelled", func(t *testing.T) { + withProcessBar(t, func(pb *processbar.Model) { + assert.Equal(t, processbar.Cancelled, bulkRenameOperation(pb, []RenamePreview{})) + }) + }) + + t.Run("handles rename error", func(t *testing.T) { + tmpDir := t.TempDir() + oldFile := filepath.Join(tmpDir, "old1.txt") + require.NoError(t, os.WriteFile(oldFile, []byte("content1"), 0644)) + + previews := []RenamePreview{ + {oldFile, "old1.txt", "new1.txt", ""}, + {filepath.Join(tmpDir, "nonexistent.txt"), "nonexistent.txt", "new2.txt", ""}, + } + + withProcessBar(t, func(pb *processbar.Model) { + state := bulkRenameOperation(pb, previews) + assert.Equal(t, processbar.Failed, state) + assert.FileExists(t, filepath.Join(tmpDir, "new1.txt")) + }) + }) + + t.Run("large file count", func(t *testing.T) { + tmpDir := t.TempDir() + fileCount := 100 + var previews []RenamePreview + + for i := 0; i < fileCount; i++ { + name := "file" + strconv.Itoa(i) + ".txt" + path := filepath.Join(tmpDir, name) + require.NoError(t, os.WriteFile(path, []byte("content"), 0644)) + previews = append(previews, RenamePreview{path, name, "renamed" + strconv.Itoa(i) + ".txt", ""}) + } + + withProcessBar(t, func(pb *processbar.Model) { + state := bulkRenameOperation(pb, previews) + assert.Equal(t, processbar.Successful, state) + + for i := 0; i < fileCount; i++ { + assert.FileExists(t, filepath.Join(tmpDir, "renamed"+strconv.Itoa(i)+".txt")) + } + }) + }) +} diff --git a/src/internal/ui/bulk_rename/navigation.go b/src/internal/ui/bulk_rename/navigation.go new file mode 100644 index 000000000..7f2ea752a --- /dev/null +++ b/src/internal/ui/bulk_rename/navigation.go @@ -0,0 +1,59 @@ +package bulkrename + +func (m *Model) navigateCursor(delta int) { + if delta > 0 { + m.navigateDown() + } else { + m.navigateUp() + } +} + +func (m *Model) navigateUp() { + if m.cursor > 0 { + m.cursor-- + m.focusInput() + } +} + +func (m *Model) navigateDown() { + if m.cursor < 1 { + m.cursor++ + m.focusInput() + } +} + +func (m *Model) focusInput() { + m.findInput.Blur() + m.replaceInput.Blur() + m.prefixInput.Blur() + m.suffixInput.Blur() + + switch m.renameType { + case FindReplace: + if m.cursor == 0 { + m.findInput.Focus() + } else { + m.replaceInput.Focus() + } + case AddPrefix: + m.prefixInput.Focus() + case AddSuffix: + m.suffixInput.Focus() + } +} + +func (m *Model) nextType() { + m.renameType = RenameType((int(m.renameType) + 1) % 6) + m.focusInput() + m.preview = nil +} + +func (m *Model) prevType() { + newType := int(m.renameType) - 1 + if newType < 0 { + newType = 5 + } + m.renameType = RenameType(newType) + m.focusInput() + m.preview = nil +} \ No newline at end of file diff --git a/src/internal/ui/bulk_rename/render.go b/src/internal/ui/bulk_rename/render.go new file mode 100644 index 000000000..cbb80ac11 --- /dev/null +++ b/src/internal/ui/bulk_rename/render.go @@ -0,0 +1,252 @@ +package bulkrename + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/yorukot/superfile/src/config/icon" + "github.com/yorukot/superfile/src/internal/common" + "github.com/yorukot/superfile/src/internal/ui" + "github.com/yorukot/superfile/src/internal/ui/rendering" +) + +const ( + modalWidth = 80 + modalHeight = 25 + leftColWidth = 20 + rightColWidth = 56 + columnHeight = 6 + maxPreviewItems = 3 +) + +// Render renders the bulk rename modal +func (m *Model) Render() string { + if !m.open { + return "" + } + + r := ui.HelpMenuRenderer(modalHeight, modalWidth) + + m.renderTitle(r) + r.AddSection() + m.renderTypeOptionsAndInputs(r) + r.AddSection() + m.renderPreview(r) + r.AddSection() + m.renderTips(r) + + if m.errorMessage != "" { + r.AddSection() + m.renderError(r) + } + + return r.Render() +} + +func (m *Model) renderTitle(r *rendering.Renderer) { + count := len(m.selectedFiles) + title := common.ModalTitleStyle.Render(" Bulk Rename") + + common.ModalStyle.Render(fmt.Sprintf(" (%d files selected)", count)) + r.AddLines(title) +} + +func (m *Model) renderTypeOptionsAndInputs(r *rendering.Renderer) { + typeOptions := m.renderTypeOptions() + + inputs := m.renderInputs() + leftStyle := lipgloss.NewStyle(). + Width(leftColWidth). + Height(columnHeight). + Background(common.ModalBGColor) + + rightStyle := lipgloss.NewStyle(). + Width(rightColWidth). + Height(columnHeight). + Background(common.ModalBGColor) + + separator := lipgloss.NewStyle(). + Width(2). + Height(columnHeight). + Background(common.ModalBGColor). + Render(" ") + + combined := lipgloss.JoinHorizontal( + lipgloss.Top, + leftStyle.Render(typeOptions), + separator, + rightStyle.Render(inputs), + ) + + r.AddLines(combined) +} + +func (m *Model) renderTypeOptions() string { + types := []string{ + "Find & Replace", + "Add Prefix", + "Add Suffix", + "Add Numbering", + "Change Case", + "Editor Mode", + } + + cursorColor := GetCursorColor() + typeStyle := lipgloss.NewStyle(). + Width(leftColWidth). + Background(common.ModalBGColor). + Foreground(common.ModalFGColor) + + var result string + for i, typeName := range types { + cursorIcon := icon.Cursor + if !common.Config.Nerdfont { + cursorIcon = ">" + } + + line := " " + typeName + style := typeStyle + if i == int(m.renameType) { + line = " " + cursorIcon + " " + typeName + style = typeStyle.Foreground(cursorColor) + } + if i > 0 { + result += "\n" + } + result += style.Render(line) + } + return result +} + +func (m *Model) renderInputs() string { + inputStyle := lipgloss.NewStyle(). + Width(rightColWidth). + Background(common.ModalBGColor) + + labelStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(common.ModalFGColor) + + activeLabelStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(GetCursorColor()) + + switch m.renameType { + case FindReplace: + return m.renderFindReplaceInputs(inputStyle, labelStyle, activeLabelStyle) + case AddPrefix: + return inputStyle.Render(activeLabelStyle.Render("Prefix: ") + m.prefixInput.View()) + case AddSuffix: + return inputStyle.Render(activeLabelStyle.Render("Suffix: ") + m.suffixInput.View()) + case AddNumbering: + return m.renderNumberingInputs(inputStyle, labelStyle) + case ChangeCase: + return m.renderCaseOptions(inputStyle, labelStyle) + case EditorMode: + return inputStyle.Render(labelStyle.Render("Opens your $EDITOR\nwith list of filenames")) + } + + return "" +} + +func (m *Model) renderFindReplaceInputs(inputStyle, labelStyle, activeLabelStyle lipgloss.Style) string { + findStyle := labelStyle + replaceStyle := labelStyle + if m.cursor == 0 { + findStyle = activeLabelStyle + } + if m.cursor == 1 { + replaceStyle = activeLabelStyle + } + + findLine := findStyle.Render("Find: ") + m.findInput.View() + replaceLine := replaceStyle.Render("Replace: ") + m.replaceInput.View() + return inputStyle.Render(findLine) + "\n" + inputStyle.Render(replaceLine) +} + +func (m *Model) renderNumberingInputs(inputStyle, labelStyle lipgloss.Style) string { + numberText := fmt.Sprintf("Start number: %d\n(Use ↑/↓ to adjust)", m.startNumber) + return inputStyle.Render(labelStyle.Render(numberText)) +} + +func (m *Model) renderCaseOptions(inputStyle, labelStyle lipgloss.Style) string { + caseTypes := []string{"lowercase", "UPPERCASE", "Title Case"} + cursorColor := GetCursorColor() + var result string + + for i, caseType := range caseTypes { + style := labelStyle + cursorIcon := icon.Cursor + if !common.Config.Nerdfont { + cursorIcon = ">" + } + + line := " " + caseType + if i == int(m.caseType) { + line = " " + cursorIcon + " " + caseType + style = labelStyle.Foreground(cursorColor) + } + result += inputStyle.Render(style.Render(line)) + "\n" + } + return result +} + +func (m *Model) renderPreview(r *rendering.Renderer) { + if len(m.preview) == 0 { + m.preview = m.GeneratePreview() + } + + previewCount := min(maxPreviewItems, len(m.preview)) + if previewCount == 0 { + return + } + + previewTitleStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(common.ModalFGColor) + + r.AddLines(previewTitleStyle.Render(" Preview:")) + + for i := range previewCount { + preview := m.preview[i] + availableWidth := modalWidth - 6 + truncatedName := common.TruncateText(preview.NewName, availableWidth, "...") + + lineStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(common.ModalFGColor) + + if preview.Error != "" { + errorStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(lipgloss.Color(common.Theme.Error)) + + r.AddLines(errorStyle.Render(" " + truncatedName)) + r.AddLines(errorStyle.Render(" " + fmt.Sprintf("(%s)", preview.Error))) + } else { + r.AddLines(lineStyle.Render(" " + truncatedName)) + } + } + + if len(m.preview) > previewCount { + moreText := fmt.Sprintf(" ... and %d more files", len(m.preview)-previewCount) + moreStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(common.ModalFGColor) + r.AddLines(moreStyle.Render(" " + moreText)) + } +} + +func (m *Model) renderTips(r *rendering.Renderer) { + tips := " Tab/Shift+Tab: Change type | ↑/↓: Navigate | Enter: Rename | Esc: Cancel" + tipsStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(common.ModalFGColor) + r.AddLines(tipsStyle.Render(tips)) +} + +func (m *Model) renderError(r *rendering.Renderer) { + errorStyle := lipgloss.NewStyle(). + Background(common.ModalBGColor). + Foreground(lipgloss.Color(common.Theme.Error)) + r.AddLines(errorStyle.Render(" " + m.errorMessage)) +} diff --git a/src/internal/ui/bulk_rename/type.go b/src/internal/ui/bulk_rename/type.go new file mode 100644 index 000000000..97b1d9a35 --- /dev/null +++ b/src/internal/ui/bulk_rename/type.go @@ -0,0 +1,60 @@ +package bulkrename + +import ( + "github.com/charmbracelet/bubbles/textinput" + "github.com/yorukot/superfile/src/internal/ui/processbar" +) + +type RenameType int +type CaseType int + +type Model struct { + open bool + renameType RenameType + caseType CaseType + cursor int + startNumber int + errorMessage string + + findInput textinput.Model + replaceInput textinput.Model + prefixInput textinput.Model + suffixInput textinput.Model + + preview []RenamePreview + + reqCnt int + + width int + height int + + selectedFiles []string + currentDir string +} + +type RenamePreview struct { + OldPath string + OldName string + NewName string + Error string +} + +type BulkRenameAction struct { + Previews []RenamePreview +} + +type UpdateMsg struct { + reqID int +} + +type EditorModeAction struct { + TmpfilePath string + Editor string + SelectedFiles []string + CurrentDir string +} + +type BulkRenameResultMsg struct { + state processbar.ProcessState + count int +} diff --git a/src/superfile_config/hotkeys.toml b/src/superfile_config/hotkeys.toml index 0a0be27b3..d682e4705 100644 --- a/src/superfile_config/hotkeys.toml +++ b/src/superfile_config/hotkeys.toml @@ -1,5 +1,6 @@ # ================================================================================================= # Global hotkeys (cannot conflict with other hotkeys) + confirm = ['enter', 'right', 'l'] quit = ['q', 'esc'] cd_quit = ['Q', ''] @@ -59,3 +60,8 @@ search_bar = ['/', ''] file_panel_select_mode_items_select_down = ['shift+down', 'J'] file_panel_select_mode_items_select_up = ['shift+up', 'K'] file_panel_select_all_items = ['A', ''] +# ================================================================================================= +# Bulk rename hotkeys +bulk_rename = ['B', ''] +nav_bulk_rename = ['tab',''] +rev_nav_bulk_rename = ['shift+tab',''] diff --git a/src/superfile_config/vimHotkeys.toml b/src/superfile_config/vimHotkeys.toml index a94f2cf60..ee9f64a79 100644 --- a/src/superfile_config/vimHotkeys.toml +++ b/src/superfile_config/vimHotkeys.toml @@ -60,3 +60,8 @@ search_bar = ['/', ''] file_panel_select_mode_items_select_down = ['J', ''] file_panel_select_mode_items_select_up = ['K', ''] file_panel_select_all_items = ['A', ''] +# ================================================================================================= +# Bulk rename hotkeys +bulk_rename = ['B', ''] +nav_bulk_rename = ['tab',''] +rev_nav_bulk_rename = ['shift+tab',''] diff --git a/website/src/content/docs/list/hotkey-list.md b/website/src/content/docs/list/hotkey-list.md index 98f0a968d..5e562276d 100644 --- a/website/src/content/docs/list/hotkey-list.md +++ b/website/src/content/docs/list/hotkey-list.md @@ -64,6 +64,7 @@ Quit superfile and cd to current folder "cd_quit" require the same scripts as [" | ---------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------- | | Create file or folder(/ ends with creating a folder) | `ctrl+n` | `file_panel_item_create` | | Rename file or folder | `ctrl+r` | `file_panel_item_rename` | +| Bulk rename files (select mode only) | `B` (shift+b) | `bulk_rename` (select mode) | | Copy file or folder (or both) | `ctrl+c` | `copy_single_item` (normal mode)
`file_panel_select_mode_item_copy` (select mode) | | Cut file or folder (or both) | `ctrl+x` | `file_panel_select_mode_item_cut` | | Paste all items in your clipboard | `ctrl+v`, `ctrl+w` | `paste_item` |