beniya 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/beniya/bookmark_manager.rb +173 -0
- data/lib/beniya/dialog_renderer.rb +127 -0
- data/lib/beniya/file_operations.rb +231 -0
- data/lib/beniya/filter_manager.rb +114 -0
- data/lib/beniya/logger.rb +103 -0
- data/lib/beniya/selection_manager.rb +79 -0
- data/lib/beniya/text_utils.rb +92 -0
- data/lib/beniya/version.rb +1 -1
- data/lib/beniya/zoxide_integration.rb +188 -0
- data/lib/beniya.rb +9 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c82332a61b93f69a73371f2f9d4aa4488d04fc45b12f5db958bd0597476a8550
|
|
4
|
+
data.tar.gz: 5f689d6a5fb75451d3f8b75ff9f09343f342d80e451b19de39e0a024fdf688dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8e95d8c526f42619d798107a447b103a592005699a06392c61bf05919df0fe090c2066dc427aa6e047d35ec8d19285f11cd6696bf9ff4aa96d810b9bb465a82
|
|
7
|
+
data.tar.gz: 6baebd7f5d4c288589d0f1a88b7ed871f0d344529787cc42356ed741c51ca47aa8758c7f25dd4dc9e31ac3804180ea1e23ae14e80642ffb7ee972e7622aed32e
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'bookmark'
|
|
4
|
+
require_relative 'config_loader'
|
|
5
|
+
|
|
6
|
+
module Beniya
|
|
7
|
+
# Manages bookmark operations with interactive UI
|
|
8
|
+
class BookmarkManager
|
|
9
|
+
def initialize(bookmark = nil, dialog_renderer = nil)
|
|
10
|
+
@bookmark = bookmark || Bookmark.new
|
|
11
|
+
@dialog_renderer = dialog_renderer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Show bookmark menu and handle user selection
|
|
15
|
+
# @param current_path [String] Current directory path
|
|
16
|
+
# @return [Symbol, nil] Action to perform (:navigate, :add, :list, :remove, :cancel)
|
|
17
|
+
def show_menu(current_path)
|
|
18
|
+
return :cancel unless @dialog_renderer
|
|
19
|
+
|
|
20
|
+
title = 'Bookmark Menu'
|
|
21
|
+
content_lines = [
|
|
22
|
+
'',
|
|
23
|
+
'[A]dd current directory to bookmarks',
|
|
24
|
+
'[L]ist bookmarks',
|
|
25
|
+
'[R]emove bookmark',
|
|
26
|
+
'',
|
|
27
|
+
'Press 1-9 to go to bookmark directly',
|
|
28
|
+
'',
|
|
29
|
+
'Press any other key to cancel'
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
dialog_width = 45
|
|
33
|
+
dialog_height = 4 + content_lines.length
|
|
34
|
+
x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
|
|
35
|
+
|
|
36
|
+
@dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
|
|
37
|
+
border_color: "\e[34m", # Blue
|
|
38
|
+
title_color: "\e[1;34m", # Bold blue
|
|
39
|
+
content_color: "\e[37m" # White
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
# Wait for key input
|
|
43
|
+
loop do
|
|
44
|
+
input = STDIN.getch.downcase
|
|
45
|
+
|
|
46
|
+
case input
|
|
47
|
+
when 'a'
|
|
48
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
49
|
+
return { action: :add, path: current_path }
|
|
50
|
+
when 'l'
|
|
51
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
52
|
+
return { action: :list }
|
|
53
|
+
when 'r'
|
|
54
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
55
|
+
return { action: :remove }
|
|
56
|
+
when '1', '2', '3', '4', '5', '6', '7', '8', '9'
|
|
57
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
58
|
+
return { action: :navigate, number: input.to_i }
|
|
59
|
+
else
|
|
60
|
+
# Cancel
|
|
61
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
62
|
+
return { action: :cancel }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Add a bookmark interactively
|
|
68
|
+
# @param path [String] Path to bookmark
|
|
69
|
+
# @return [Boolean] Success status
|
|
70
|
+
def add_interactive(path)
|
|
71
|
+
print ConfigLoader.message('bookmark.input_name') || 'Enter bookmark name: '
|
|
72
|
+
name = STDIN.gets.chomp
|
|
73
|
+
return false if name.empty?
|
|
74
|
+
|
|
75
|
+
if @bookmark.add(path, name)
|
|
76
|
+
puts "\n#{ConfigLoader.message('bookmark.added') || 'Bookmark added'}: #{name}"
|
|
77
|
+
true
|
|
78
|
+
else
|
|
79
|
+
puts "\n#{ConfigLoader.message('bookmark.add_failed') || 'Failed to add bookmark'}"
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Remove a bookmark interactively
|
|
85
|
+
# @return [Boolean] Success status
|
|
86
|
+
def remove_interactive
|
|
87
|
+
bookmarks = @bookmark.list
|
|
88
|
+
|
|
89
|
+
if bookmarks.empty?
|
|
90
|
+
puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
|
|
91
|
+
return false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
puts "\nBookmarks:"
|
|
95
|
+
bookmarks.each_with_index do |bookmark, index|
|
|
96
|
+
puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
print ConfigLoader.message('bookmark.input_number') || 'Enter number to remove: '
|
|
100
|
+
input = STDIN.gets.chomp
|
|
101
|
+
number = input.to_i
|
|
102
|
+
|
|
103
|
+
if number > 0 && number <= bookmarks.length
|
|
104
|
+
bookmark_to_remove = bookmarks[number - 1]
|
|
105
|
+
if @bookmark.remove(bookmark_to_remove[:name])
|
|
106
|
+
puts "\n#{ConfigLoader.message('bookmark.removed') || 'Bookmark removed'}: #{bookmark_to_remove[:name]}"
|
|
107
|
+
true
|
|
108
|
+
else
|
|
109
|
+
puts "\n#{ConfigLoader.message('bookmark.remove_failed') || 'Failed to remove bookmark'}"
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
puts "\n#{ConfigLoader.message('bookmark.invalid_number') || 'Invalid number'}"
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# List all bookmarks interactively
|
|
119
|
+
# @return [Boolean] Success status
|
|
120
|
+
def list_interactive
|
|
121
|
+
bookmarks = @bookmark.list
|
|
122
|
+
|
|
123
|
+
if bookmarks.empty?
|
|
124
|
+
puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
|
|
125
|
+
return false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
puts "\nBookmarks:"
|
|
129
|
+
bookmarks.each_with_index do |bookmark, index|
|
|
130
|
+
puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Get bookmark by number
|
|
137
|
+
# @param number [Integer] Bookmark number (1-9)
|
|
138
|
+
# @return [Hash, nil] Bookmark hash with :path and :name
|
|
139
|
+
def find_by_number(number)
|
|
140
|
+
@bookmark.find_by_number(number)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Validate bookmark path exists
|
|
144
|
+
# @param bookmark [Hash] Bookmark hash
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def path_exists?(bookmark)
|
|
147
|
+
return false unless bookmark
|
|
148
|
+
|
|
149
|
+
Dir.exist?(bookmark[:path])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Get all bookmarks
|
|
153
|
+
# @return [Array<Hash>]
|
|
154
|
+
def list
|
|
155
|
+
@bookmark.list
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Add bookmark
|
|
159
|
+
# @param path [String] Path to bookmark
|
|
160
|
+
# @param name [String] Bookmark name
|
|
161
|
+
# @return [Boolean] Success status
|
|
162
|
+
def add(path, name)
|
|
163
|
+
@bookmark.add(path, name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Remove bookmark
|
|
167
|
+
# @param name [String] Bookmark name
|
|
168
|
+
# @return [Boolean] Success status
|
|
169
|
+
def remove(name)
|
|
170
|
+
@bookmark.remove(name)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'text_utils'
|
|
4
|
+
|
|
5
|
+
module Beniya
|
|
6
|
+
# Renders floating dialog windows in the terminal
|
|
7
|
+
class DialogRenderer
|
|
8
|
+
include TextUtils
|
|
9
|
+
|
|
10
|
+
# Draw a floating window with title, content, and customizable colors
|
|
11
|
+
# @param x [Integer] X position (column)
|
|
12
|
+
# @param y [Integer] Y position (row)
|
|
13
|
+
# @param width [Integer] Window width
|
|
14
|
+
# @param height [Integer] Window height
|
|
15
|
+
# @param title [String, nil] Window title (optional)
|
|
16
|
+
# @param content_lines [Array<String>] Content lines to display
|
|
17
|
+
# @param options [Hash] Customization options
|
|
18
|
+
# @option options [String] :border_color Border color ANSI code
|
|
19
|
+
# @option options [String] :title_color Title color ANSI code
|
|
20
|
+
# @option options [String] :content_color Content color ANSI code
|
|
21
|
+
def draw_floating_window(x, y, width, height, title, content_lines, options = {})
|
|
22
|
+
# Default options
|
|
23
|
+
border_color = options[:border_color] || "\e[37m" # White
|
|
24
|
+
title_color = options[:title_color] || "\e[1;33m" # Bold yellow
|
|
25
|
+
content_color = options[:content_color] || "\e[37m" # White
|
|
26
|
+
reset_color = "\e[0m"
|
|
27
|
+
|
|
28
|
+
# Draw top border
|
|
29
|
+
print "\e[#{y};#{x}H#{border_color}┌#{'─' * (width - 2)}┐#{reset_color}"
|
|
30
|
+
|
|
31
|
+
# Draw title line if title exists
|
|
32
|
+
if title
|
|
33
|
+
title_width = TextUtils.display_width(title)
|
|
34
|
+
title_padding = (width - 2 - title_width) / 2
|
|
35
|
+
padded_title = ' ' * title_padding + title
|
|
36
|
+
title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
|
|
37
|
+
print "\e[#{y + 1};#{x}H#{border_color}│#{title_color}#{title_line}#{border_color}│#{reset_color}"
|
|
38
|
+
|
|
39
|
+
# Draw title separator
|
|
40
|
+
print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * (width - 2)}┤#{reset_color}"
|
|
41
|
+
content_start_y = y + 3
|
|
42
|
+
else
|
|
43
|
+
content_start_y = y + 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Draw content lines
|
|
47
|
+
content_height = title ? height - 4 : height - 2
|
|
48
|
+
content_lines.each_with_index do |line, index|
|
|
49
|
+
break if index >= content_height
|
|
50
|
+
|
|
51
|
+
line_y = content_start_y + index
|
|
52
|
+
line_content = TextUtils.pad_string_to_width(line, width - 2)
|
|
53
|
+
print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fill remaining lines with empty space
|
|
57
|
+
remaining_lines = content_height - content_lines.length
|
|
58
|
+
remaining_lines.times do |i|
|
|
59
|
+
line_y = content_start_y + content_lines.length + i
|
|
60
|
+
empty_line = ' ' * (width - 2)
|
|
61
|
+
print "\e[#{line_y};#{x}H#{border_color}│#{empty_line}│#{reset_color}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Draw bottom border
|
|
65
|
+
bottom_y = y + height - 1
|
|
66
|
+
print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * (width - 2)}┘#{reset_color}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Calculate center position for a window
|
|
70
|
+
# @param content_width [Integer] Window width
|
|
71
|
+
# @param content_height [Integer] Window height
|
|
72
|
+
# @return [Array<Integer>] [x, y] position
|
|
73
|
+
def calculate_center(content_width, content_height)
|
|
74
|
+
# Get terminal size
|
|
75
|
+
console = IO.console
|
|
76
|
+
if console
|
|
77
|
+
screen_width, screen_height = console.winsize.reverse
|
|
78
|
+
else
|
|
79
|
+
screen_width = 80
|
|
80
|
+
screen_height = 24
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Calculate center position
|
|
84
|
+
x = [(screen_width - content_width) / 2, 1].max
|
|
85
|
+
y = [(screen_height - content_height) / 2, 1].max
|
|
86
|
+
|
|
87
|
+
[x, y]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Clear a rectangular area on the screen
|
|
91
|
+
# @param x [Integer] X position
|
|
92
|
+
# @param y [Integer] Y position
|
|
93
|
+
# @param width [Integer] Area width
|
|
94
|
+
# @param height [Integer] Area height
|
|
95
|
+
def clear_area(x, y, width, height)
|
|
96
|
+
height.times do |row|
|
|
97
|
+
print "\e[#{y + row};#{x}H#{' ' * width}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Calculate appropriate dimensions for content
|
|
102
|
+
# @param content_lines [Array<String>] Content lines
|
|
103
|
+
# @param options [Hash] Options
|
|
104
|
+
# @option options [String, nil] :title Window title
|
|
105
|
+
# @option options [Integer] :min_width Minimum width (default: 30)
|
|
106
|
+
# @option options [Integer] :max_width Maximum width (default: 80)
|
|
107
|
+
# @return [Array<Integer>] [width, height]
|
|
108
|
+
def calculate_dimensions(content_lines, options = {})
|
|
109
|
+
title = options[:title]
|
|
110
|
+
min_width = options[:min_width] || 30
|
|
111
|
+
max_width = options[:max_width] || 80
|
|
112
|
+
|
|
113
|
+
# Calculate required width based on content
|
|
114
|
+
max_content_width = content_lines.map { |line| TextUtils.display_width(line) }.max || 0
|
|
115
|
+
title_width = title ? TextUtils.display_width(title) : 0
|
|
116
|
+
required_width = [max_content_width, title_width].max + 4 # +4 for borders and padding
|
|
117
|
+
|
|
118
|
+
width = [[required_width, min_width].max, max_width].min
|
|
119
|
+
|
|
120
|
+
# Calculate height: borders + title (if exists) + separator + content
|
|
121
|
+
height = content_lines.length + 2 # +2 for top and bottom borders
|
|
122
|
+
height += 2 if title # +2 for title line and separator
|
|
123
|
+
|
|
124
|
+
[width, height]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Beniya
|
|
6
|
+
# Handles file operations (move, copy, delete)
|
|
7
|
+
class FileOperations
|
|
8
|
+
# Operation result structure
|
|
9
|
+
# @return [Hash] { success: Boolean, message: String, count: Integer }
|
|
10
|
+
OperationResult = Struct.new(:success, :message, :count, :errors, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# Move files/directories to destination
|
|
13
|
+
# @param items [Array<String>] Item names to move
|
|
14
|
+
# @param source_directory [String] Source directory path
|
|
15
|
+
# @param destination [String] Destination directory path
|
|
16
|
+
# @return [OperationResult]
|
|
17
|
+
def move(items, source_directory, destination)
|
|
18
|
+
perform_operation(:move, items, source_directory, destination)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Copy files/directories to destination
|
|
22
|
+
# @param items [Array<String>] Item names to copy
|
|
23
|
+
# @param source_directory [String] Source directory path
|
|
24
|
+
# @param destination [String] Destination directory path
|
|
25
|
+
# @return [OperationResult]
|
|
26
|
+
def copy(items, source_directory, destination)
|
|
27
|
+
perform_operation(:copy, items, source_directory, destination)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Delete files/directories
|
|
31
|
+
# @param items [Array<String>] Item names to delete
|
|
32
|
+
# @param source_directory [String] Source directory path
|
|
33
|
+
# @return [OperationResult]
|
|
34
|
+
def delete(items, source_directory)
|
|
35
|
+
success_count = 0
|
|
36
|
+
error_messages = []
|
|
37
|
+
|
|
38
|
+
items.each do |item_name|
|
|
39
|
+
item_path = File.join(source_directory, item_name)
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
# Check if file/directory exists
|
|
43
|
+
unless File.exist?(item_path)
|
|
44
|
+
error_messages << "#{item_name}: File not found"
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
is_directory = File.directory?(item_path)
|
|
49
|
+
|
|
50
|
+
if is_directory
|
|
51
|
+
FileUtils.rm_rf(item_path)
|
|
52
|
+
else
|
|
53
|
+
FileUtils.rm(item_path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Verify deletion
|
|
57
|
+
sleep(0.01) # Wait for file system sync
|
|
58
|
+
if File.exist?(item_path)
|
|
59
|
+
error_messages << "#{item_name}: Deletion failed"
|
|
60
|
+
else
|
|
61
|
+
success_count += 1
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
error_messages << "#{item_name}: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
OperationResult.new(
|
|
69
|
+
success: error_messages.empty?,
|
|
70
|
+
message: build_delete_message(success_count, items.length),
|
|
71
|
+
count: success_count,
|
|
72
|
+
errors: error_messages
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Create a new file
|
|
77
|
+
# @param directory [String] Directory path
|
|
78
|
+
# @param filename [String] File name
|
|
79
|
+
# @return [OperationResult]
|
|
80
|
+
def create_file(directory, filename)
|
|
81
|
+
# Validate filename
|
|
82
|
+
if filename.include?('/') || filename.include?('\\')
|
|
83
|
+
return OperationResult.new(
|
|
84
|
+
success: false,
|
|
85
|
+
message: 'Invalid filename: cannot contain path separators',
|
|
86
|
+
count: 0,
|
|
87
|
+
errors: []
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
file_path = File.join(directory, filename)
|
|
92
|
+
|
|
93
|
+
# Check if file already exists
|
|
94
|
+
if File.exist?(file_path)
|
|
95
|
+
return OperationResult.new(
|
|
96
|
+
success: false,
|
|
97
|
+
message: 'File already exists',
|
|
98
|
+
count: 0,
|
|
99
|
+
errors: []
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
File.write(file_path, '')
|
|
105
|
+
OperationResult.new(
|
|
106
|
+
success: true,
|
|
107
|
+
message: "File created: #{filename}",
|
|
108
|
+
count: 1,
|
|
109
|
+
errors: []
|
|
110
|
+
)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
OperationResult.new(
|
|
113
|
+
success: false,
|
|
114
|
+
message: "Creation error: #{e.message}",
|
|
115
|
+
count: 0,
|
|
116
|
+
errors: [e.message]
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Create a new directory
|
|
122
|
+
# @param parent_directory [String] Parent directory path
|
|
123
|
+
# @param dirname [String] Directory name
|
|
124
|
+
# @return [OperationResult]
|
|
125
|
+
def create_directory(parent_directory, dirname)
|
|
126
|
+
# Validate dirname
|
|
127
|
+
if dirname.include?('/') || dirname.include?('\\')
|
|
128
|
+
return OperationResult.new(
|
|
129
|
+
success: false,
|
|
130
|
+
message: 'Invalid directory name: cannot contain path separators',
|
|
131
|
+
count: 0,
|
|
132
|
+
errors: []
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
dir_path = File.join(parent_directory, dirname)
|
|
137
|
+
|
|
138
|
+
# Check if directory already exists
|
|
139
|
+
if File.exist?(dir_path)
|
|
140
|
+
return OperationResult.new(
|
|
141
|
+
success: false,
|
|
142
|
+
message: 'Directory already exists',
|
|
143
|
+
count: 0,
|
|
144
|
+
errors: []
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
Dir.mkdir(dir_path)
|
|
150
|
+
OperationResult.new(
|
|
151
|
+
success: true,
|
|
152
|
+
message: "Directory created: #{dirname}",
|
|
153
|
+
count: 1,
|
|
154
|
+
errors: []
|
|
155
|
+
)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
OperationResult.new(
|
|
158
|
+
success: false,
|
|
159
|
+
message: "Creation error: #{e.message}",
|
|
160
|
+
count: 0,
|
|
161
|
+
errors: [e.message]
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Perform move or copy operation
|
|
169
|
+
# @param operation [Symbol] :move or :copy
|
|
170
|
+
# @param items [Array<String>] Item names
|
|
171
|
+
# @param source_directory [String] Source directory path
|
|
172
|
+
# @param destination [String] Destination directory path
|
|
173
|
+
# @return [OperationResult]
|
|
174
|
+
def perform_operation(operation, items, source_directory, destination)
|
|
175
|
+
success_count = 0
|
|
176
|
+
error_messages = []
|
|
177
|
+
|
|
178
|
+
items.each do |item_name|
|
|
179
|
+
source_path = File.join(source_directory, item_name)
|
|
180
|
+
dest_path = File.join(destination, item_name)
|
|
181
|
+
|
|
182
|
+
begin
|
|
183
|
+
if File.exist?(dest_path)
|
|
184
|
+
error_messages << "#{item_name}: Already exists in destination"
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
case operation
|
|
189
|
+
when :move
|
|
190
|
+
FileUtils.mv(source_path, dest_path)
|
|
191
|
+
when :copy
|
|
192
|
+
if File.directory?(source_path)
|
|
193
|
+
FileUtils.cp_r(source_path, dest_path)
|
|
194
|
+
else
|
|
195
|
+
FileUtils.cp(source_path, dest_path)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
success_count += 1
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
operation_name = operation == :move ? 'move' : 'copy'
|
|
202
|
+
error_messages << "#{item_name}: Failed to #{operation_name} (#{e.message})"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
operation_name = operation == :move ? 'Moved' : 'Copied'
|
|
207
|
+
message = "#{operation_name} #{success_count} item(s)"
|
|
208
|
+
message += " (#{error_messages.length} failed)" unless error_messages.empty?
|
|
209
|
+
|
|
210
|
+
OperationResult.new(
|
|
211
|
+
success: error_messages.empty?,
|
|
212
|
+
message: message,
|
|
213
|
+
count: success_count,
|
|
214
|
+
errors: error_messages
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Build delete operation message
|
|
219
|
+
# @param success_count [Integer] Number of successfully deleted items
|
|
220
|
+
# @param total_count [Integer] Total number of items
|
|
221
|
+
# @return [String]
|
|
222
|
+
def build_delete_message(success_count, total_count)
|
|
223
|
+
if success_count == total_count
|
|
224
|
+
"Deleted #{success_count} item(s)"
|
|
225
|
+
else
|
|
226
|
+
failed_count = total_count - success_count
|
|
227
|
+
"#{success_count} deleted, #{failed_count} failed"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beniya
|
|
4
|
+
# Manages filtering of directory entries
|
|
5
|
+
class FilterManager
|
|
6
|
+
attr_reader :filter_query, :filter_mode
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@filter_mode = false
|
|
10
|
+
@filter_query = ''
|
|
11
|
+
@original_entries = []
|
|
12
|
+
@filtered_entries = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Start filter mode with the given entries
|
|
16
|
+
# @param entries [Array<Hash>] Directory entries to filter
|
|
17
|
+
def start_filter_mode(entries)
|
|
18
|
+
@filter_mode = true
|
|
19
|
+
@filter_query = ''
|
|
20
|
+
@original_entries = entries.dup
|
|
21
|
+
@filtered_entries = @original_entries.dup
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Handle filter input character
|
|
26
|
+
# @param key [String] Input key
|
|
27
|
+
# @return [Symbol] :exit_clear, :exit_keep, :continue, or :backspace_exit
|
|
28
|
+
def handle_filter_input(key)
|
|
29
|
+
case key
|
|
30
|
+
when "\e" # ESC - clear filter and exit
|
|
31
|
+
:exit_clear
|
|
32
|
+
when "\r", "\n" # Enter - keep filter and exit
|
|
33
|
+
:exit_keep
|
|
34
|
+
when "\u007f", "\b" # Backspace
|
|
35
|
+
if @filter_query.length > 0
|
|
36
|
+
@filter_query = @filter_query[0...-1]
|
|
37
|
+
apply_filter
|
|
38
|
+
:continue
|
|
39
|
+
else
|
|
40
|
+
:backspace_exit
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
# Printable characters (alphanumeric, symbols, Japanese, etc.)
|
|
44
|
+
if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
|
|
45
|
+
@filter_query += key
|
|
46
|
+
apply_filter
|
|
47
|
+
:continue
|
|
48
|
+
elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
|
|
49
|
+
@filter_query += key
|
|
50
|
+
apply_filter
|
|
51
|
+
:continue
|
|
52
|
+
else
|
|
53
|
+
# Ignore other keys (Ctrl+c, etc.)
|
|
54
|
+
:continue
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Apply filter to entries
|
|
60
|
+
# @return [Array<Hash>] Filtered entries
|
|
61
|
+
def apply_filter
|
|
62
|
+
if @filter_query.empty?
|
|
63
|
+
@filtered_entries = @original_entries.dup
|
|
64
|
+
else
|
|
65
|
+
query_downcase = @filter_query.downcase
|
|
66
|
+
@filtered_entries = @original_entries.select do |entry|
|
|
67
|
+
entry[:name].downcase.include?(query_downcase)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
@filtered_entries
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Clear filter mode
|
|
74
|
+
def clear_filter
|
|
75
|
+
@filter_mode = false
|
|
76
|
+
@filter_query = ''
|
|
77
|
+
@filtered_entries = []
|
|
78
|
+
@original_entries = []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Exit filter mode while keeping the filter
|
|
82
|
+
def exit_filter_mode_keep_filter
|
|
83
|
+
@filter_mode = false
|
|
84
|
+
# Keep @filter_query and @filtered_entries
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if filter is active
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def filter_active?
|
|
90
|
+
@filter_mode || !@filter_query.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get filtered entries
|
|
94
|
+
# @return [Array<Hash>]
|
|
95
|
+
def filtered_entries
|
|
96
|
+
@filtered_entries
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Update original entries (e.g., after directory refresh)
|
|
100
|
+
# @param entries [Array<Hash>] New entries
|
|
101
|
+
def update_entries(entries)
|
|
102
|
+
@original_entries = entries.dup
|
|
103
|
+
apply_filter if filter_active?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Restart filter mode with existing query
|
|
107
|
+
# @param entries [Array<Hash>] Directory entries
|
|
108
|
+
def restart_filter_mode(entries)
|
|
109
|
+
@filter_mode = true
|
|
110
|
+
@original_entries = entries.dup if @original_entries.empty?
|
|
111
|
+
apply_filter
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beniya
|
|
4
|
+
# Unified logger for debug and error messages
|
|
5
|
+
# Only logs when BENIYA_DEBUG environment variable is set to '1'
|
|
6
|
+
class Logger
|
|
7
|
+
LOG_FILE = File.join(Dir.home, '.beniya_debug.log')
|
|
8
|
+
|
|
9
|
+
# Log levels
|
|
10
|
+
DEBUG = :debug
|
|
11
|
+
INFO = :info
|
|
12
|
+
WARN = :warn
|
|
13
|
+
ERROR = :error
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Log a debug message with optional context
|
|
17
|
+
# @param message [String] The log message
|
|
18
|
+
# @param context [Hash] Additional context information
|
|
19
|
+
def debug(message, context: {})
|
|
20
|
+
return unless debug_enabled?
|
|
21
|
+
|
|
22
|
+
write_log(DEBUG, message, context)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Log an info message
|
|
26
|
+
# @param message [String] The log message
|
|
27
|
+
# @param context [Hash] Additional context information
|
|
28
|
+
def info(message, context: {})
|
|
29
|
+
return unless debug_enabled?
|
|
30
|
+
|
|
31
|
+
write_log(INFO, message, context)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Log a warning message
|
|
35
|
+
# @param message [String] The log message
|
|
36
|
+
# @param context [Hash] Additional context information
|
|
37
|
+
def warn(message, context: {})
|
|
38
|
+
return unless debug_enabled?
|
|
39
|
+
|
|
40
|
+
write_log(WARN, message, context)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Log an error message with optional exception
|
|
44
|
+
# @param message [String] The error message
|
|
45
|
+
# @param exception [Exception, nil] Optional exception object
|
|
46
|
+
# @param context [Hash] Additional context information
|
|
47
|
+
def error(message, exception: nil, context: {})
|
|
48
|
+
return unless debug_enabled?
|
|
49
|
+
|
|
50
|
+
full_context = context.dup
|
|
51
|
+
if exception
|
|
52
|
+
full_context[:exception] = exception.message
|
|
53
|
+
full_context[:backtrace] = exception.backtrace&.first(5)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
write_log(ERROR, message, full_context)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Clear the log file
|
|
60
|
+
def clear_log
|
|
61
|
+
return unless debug_enabled?
|
|
62
|
+
|
|
63
|
+
File.open(LOG_FILE, 'w') { |f| f.puts "=== Beniya Debug Log Cleared at #{Time.now} ===" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Check if debug logging is enabled
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def debug_enabled?
|
|
71
|
+
ENV['BENIYA_DEBUG'] == '1'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Write a log entry to the log file
|
|
75
|
+
# @param level [Symbol] Log level
|
|
76
|
+
# @param message [String] Log message
|
|
77
|
+
# @param context [Hash] Context information
|
|
78
|
+
def write_log(level, message, context)
|
|
79
|
+
File.open(LOG_FILE, 'a') do |f|
|
|
80
|
+
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
|
81
|
+
f.puts "[#{timestamp}] [#{level.to_s.upcase}] #{message}"
|
|
82
|
+
|
|
83
|
+
unless context.empty?
|
|
84
|
+
f.puts ' Context:'
|
|
85
|
+
context.each do |key, value|
|
|
86
|
+
if value.is_a?(Array) && value.length > 10
|
|
87
|
+
f.puts " #{key}: [#{value.length} items]"
|
|
88
|
+
else
|
|
89
|
+
f.puts " #{key}: #{value.inspect}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
f.puts ''
|
|
95
|
+
end
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
# Silently fail if we can't write to log file
|
|
98
|
+
# Don't want logging to break the application
|
|
99
|
+
warn "Failed to write to log file: #{e.message}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beniya
|
|
4
|
+
# Manages selected items (files/directories) for bulk operations
|
|
5
|
+
class SelectionManager
|
|
6
|
+
def initialize
|
|
7
|
+
@selected_items = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Toggle selection for an entry
|
|
11
|
+
# @param entry [Hash] Entry with :name key
|
|
12
|
+
# @return [Boolean] true if now selected, false if unselected
|
|
13
|
+
def toggle_selection(entry)
|
|
14
|
+
return false unless entry
|
|
15
|
+
|
|
16
|
+
if @selected_items.include?(entry[:name])
|
|
17
|
+
@selected_items.delete(entry[:name])
|
|
18
|
+
false
|
|
19
|
+
else
|
|
20
|
+
@selected_items << entry[:name]
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if an entry is selected
|
|
26
|
+
# @param entry_name [String] Entry name
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def selected?(entry_name)
|
|
29
|
+
@selected_items.include?(entry_name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get all selected items
|
|
33
|
+
# @return [Array<String>] Copy of selected items
|
|
34
|
+
def selected_items
|
|
35
|
+
@selected_items.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clear all selections
|
|
39
|
+
def clear
|
|
40
|
+
@selected_items.clear
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if any items are selected
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def any?
|
|
46
|
+
!@selected_items.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get the count of selected items
|
|
50
|
+
# @return [Integer]
|
|
51
|
+
def count
|
|
52
|
+
@selected_items.length
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Add an item to selection
|
|
56
|
+
# @param item_name [String] Item name
|
|
57
|
+
def add(item_name)
|
|
58
|
+
@selected_items << item_name unless @selected_items.include?(item_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Remove an item from selection
|
|
62
|
+
# @param item_name [String] Item name
|
|
63
|
+
def remove(item_name)
|
|
64
|
+
@selected_items.delete(item_name)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Select multiple items
|
|
68
|
+
# @param item_names [Array<String>] Item names
|
|
69
|
+
def select_multiple(item_names)
|
|
70
|
+
item_names.each { |name| add(name) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if selection is empty
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def empty?
|
|
76
|
+
@selected_items.empty?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beniya
|
|
4
|
+
# Text utility methods for display width calculation and string manipulation
|
|
5
|
+
# Handles multi-byte characters (Japanese, etc.) correctly
|
|
6
|
+
module TextUtils
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Character width constants
|
|
10
|
+
FULLWIDTH_CHAR_WIDTH = 2
|
|
11
|
+
HALFWIDTH_CHAR_WIDTH = 1
|
|
12
|
+
MULTIBYTE_THRESHOLD = 1
|
|
13
|
+
|
|
14
|
+
# Truncation constants
|
|
15
|
+
ELLIPSIS_MIN_WIDTH = 3
|
|
16
|
+
ELLIPSIS = '...'
|
|
17
|
+
|
|
18
|
+
# Line break constants
|
|
19
|
+
BREAK_POINT_THRESHOLD = 0.5 # Break after 50% of max_width
|
|
20
|
+
|
|
21
|
+
# Calculate display width of a string
|
|
22
|
+
# Full-width characters (Japanese, etc.) count as 2, half-width as 1
|
|
23
|
+
def display_width(string)
|
|
24
|
+
string.each_char.map do |char|
|
|
25
|
+
case char
|
|
26
|
+
when /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\uFF00-\uFFEF]/
|
|
27
|
+
FULLWIDTH_CHAR_WIDTH # Japanese characters (hiragana, katakana, kanji, full-width symbols)
|
|
28
|
+
when /[\u0020-\u007E]/
|
|
29
|
+
HALFWIDTH_CHAR_WIDTH # ASCII characters
|
|
30
|
+
else
|
|
31
|
+
char.bytesize > MULTIBYTE_THRESHOLD ? FULLWIDTH_CHAR_WIDTH : HALFWIDTH_CHAR_WIDTH
|
|
32
|
+
end
|
|
33
|
+
end.sum
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Truncate string to fit within max_width
|
|
37
|
+
def truncate_to_width(string, max_width)
|
|
38
|
+
return string if display_width(string) <= max_width
|
|
39
|
+
|
|
40
|
+
result = ''
|
|
41
|
+
current_width = 0
|
|
42
|
+
|
|
43
|
+
string.each_char do |char|
|
|
44
|
+
char_width = display_width(char)
|
|
45
|
+
break if current_width + char_width > max_width
|
|
46
|
+
|
|
47
|
+
result += char
|
|
48
|
+
current_width += char_width
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Add ellipsis if there's room
|
|
52
|
+
result += ELLIPSIS if max_width >= ELLIPSIS_MIN_WIDTH && current_width <= max_width - ELLIPSIS_MIN_WIDTH
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Pad string to target_width with spaces
|
|
57
|
+
def pad_string_to_width(string, target_width)
|
|
58
|
+
current_width = display_width(string)
|
|
59
|
+
if current_width >= target_width
|
|
60
|
+
truncate_to_width(string, target_width)
|
|
61
|
+
else
|
|
62
|
+
string + ' ' * (target_width - current_width)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Find the best break point for wrapping text within max_width
|
|
67
|
+
def find_break_point(line, max_width)
|
|
68
|
+
return line.length if display_width(line) <= max_width
|
|
69
|
+
|
|
70
|
+
current_width = 0
|
|
71
|
+
best_break_point = 0
|
|
72
|
+
space_break_point = nil
|
|
73
|
+
punct_break_point = nil
|
|
74
|
+
|
|
75
|
+
line.each_char.with_index do |char, index|
|
|
76
|
+
char_width = display_width(char)
|
|
77
|
+
break if current_width + char_width > max_width
|
|
78
|
+
|
|
79
|
+
current_width += char_width
|
|
80
|
+
best_break_point = index + 1
|
|
81
|
+
|
|
82
|
+
# Record break point at space
|
|
83
|
+
space_break_point = index + 1 if char == ' ' && current_width > max_width * BREAK_POINT_THRESHOLD
|
|
84
|
+
|
|
85
|
+
# Record break point at Japanese punctuation
|
|
86
|
+
punct_break_point = index + 1 if char.match?(/[、。,.!?]/) && current_width > max_width * BREAK_POINT_THRESHOLD
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
space_break_point || punct_break_point || best_break_point
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/beniya/version.rb
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Beniya
|
|
6
|
+
# Integrates with zoxide for directory history navigation
|
|
7
|
+
class ZoxideIntegration
|
|
8
|
+
# Dialog size constants
|
|
9
|
+
DIALOG_WIDTH = 45
|
|
10
|
+
DIALOG_BORDER_HEIGHT = 4
|
|
11
|
+
|
|
12
|
+
def initialize(dialog_renderer = nil)
|
|
13
|
+
@dialog_renderer = dialog_renderer
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Check if zoxide is available
|
|
17
|
+
# @return [Boolean]
|
|
18
|
+
def available?
|
|
19
|
+
system('which zoxide > /dev/null 2>&1')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get zoxide history
|
|
23
|
+
# @return [Array<Hash>] Array of { path: String, score: Float }
|
|
24
|
+
def get_history
|
|
25
|
+
return [] unless available?
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
# Get zoxide history with scores
|
|
29
|
+
output = `zoxide query --list --score 2>/dev/null`.strip
|
|
30
|
+
return [] if output.empty?
|
|
31
|
+
|
|
32
|
+
# Parse each line into path and score
|
|
33
|
+
lines = output.split("\n")
|
|
34
|
+
history = lines.map do |line|
|
|
35
|
+
# zoxide output format: "score path"
|
|
36
|
+
if line.match(/^\s*(\d+(?:\.\d+)?)\s+(.+)$/)
|
|
37
|
+
score = ::Regexp.last_match(1).to_f
|
|
38
|
+
path = ::Regexp.last_match(2).strip
|
|
39
|
+
{ path: path, score: score }
|
|
40
|
+
else
|
|
41
|
+
# No score (backward compatibility)
|
|
42
|
+
{ path: line.strip, score: 0.0 }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Filter to only existing directories
|
|
47
|
+
history.select { |entry| Dir.exist?(entry[:path]) }
|
|
48
|
+
rescue StandardError
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Show zoxide history menu and let user select
|
|
54
|
+
# @return [String, nil] Selected path or nil if cancelled
|
|
55
|
+
def show_menu
|
|
56
|
+
return nil unless @dialog_renderer
|
|
57
|
+
|
|
58
|
+
history = get_history
|
|
59
|
+
|
|
60
|
+
if history.empty?
|
|
61
|
+
show_no_history_message
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
select_from_history(history)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Add directory to zoxide history
|
|
69
|
+
# @param path [String] Directory path
|
|
70
|
+
# @return [Boolean] Success status
|
|
71
|
+
def add_to_history(path)
|
|
72
|
+
return false unless available?
|
|
73
|
+
return false unless Dir.exist?(path)
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
system("zoxide add #{Shellwords.escape(path)} > /dev/null 2>&1")
|
|
77
|
+
true
|
|
78
|
+
rescue StandardError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Show message when no history is available
|
|
86
|
+
def show_no_history_message
|
|
87
|
+
return unless @dialog_renderer
|
|
88
|
+
|
|
89
|
+
title = 'Zoxide'
|
|
90
|
+
content_lines = [
|
|
91
|
+
'',
|
|
92
|
+
'No zoxide history found.',
|
|
93
|
+
'',
|
|
94
|
+
'Zoxide learns from your directory navigation.',
|
|
95
|
+
'Use zoxide more to build up history.',
|
|
96
|
+
'',
|
|
97
|
+
'Press any key to continue...'
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
dialog_width = DIALOG_WIDTH
|
|
101
|
+
dialog_height = DIALOG_BORDER_HEIGHT + content_lines.length
|
|
102
|
+
x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
|
|
103
|
+
|
|
104
|
+
@dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
|
|
105
|
+
border_color: "\e[33m", # Yellow
|
|
106
|
+
title_color: "\e[1;33m", # Bold yellow
|
|
107
|
+
content_color: "\e[37m" # White
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
STDIN.getch
|
|
111
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Select from zoxide history
|
|
115
|
+
# @param history [Array<Hash>] History entries
|
|
116
|
+
# @return [String, nil] Selected path or nil
|
|
117
|
+
def select_from_history(history)
|
|
118
|
+
return nil unless @dialog_renderer
|
|
119
|
+
|
|
120
|
+
title = 'Zoxide History'
|
|
121
|
+
|
|
122
|
+
# Format history for display (max 20 items)
|
|
123
|
+
display_history = history.first(20)
|
|
124
|
+
content_lines = ['']
|
|
125
|
+
|
|
126
|
+
display_history.each_with_index do |entry, index|
|
|
127
|
+
# Shorten path display (replace home directory with ~)
|
|
128
|
+
display_path = entry[:path].gsub(ENV['HOME'], '~')
|
|
129
|
+
line = " #{index + 1}. #{display_path}"
|
|
130
|
+
# Truncate if too long
|
|
131
|
+
line = line[0...60] + '...' if line.length > 63
|
|
132
|
+
content_lines << line
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
content_lines << ''
|
|
136
|
+
content_lines << 'Enter number (1-' + display_history.length.to_s + ') or ESC to cancel'
|
|
137
|
+
|
|
138
|
+
dialog_width = 70
|
|
139
|
+
dialog_height = [4 + content_lines.length, 25].min
|
|
140
|
+
x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
|
|
141
|
+
|
|
142
|
+
@dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
|
|
143
|
+
border_color: "\e[36m", # Cyan
|
|
144
|
+
title_color: "\e[1;36m", # Bold cyan
|
|
145
|
+
content_color: "\e[37m" # White
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
# Number input mode
|
|
149
|
+
input_buffer = ''
|
|
150
|
+
|
|
151
|
+
loop do
|
|
152
|
+
char = STDIN.getch
|
|
153
|
+
|
|
154
|
+
case char
|
|
155
|
+
when "\e", "\x03" # ESC, Ctrl+C
|
|
156
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
157
|
+
return nil
|
|
158
|
+
when "\r", "\n" # Enter
|
|
159
|
+
unless input_buffer.empty?
|
|
160
|
+
number = input_buffer.to_i
|
|
161
|
+
if number > 0 && number <= display_history.length
|
|
162
|
+
selected_entry = display_history[number - 1]
|
|
163
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
164
|
+
return selected_entry[:path]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
# Invalid input, ask again
|
|
168
|
+
input_buffer = ''
|
|
169
|
+
when "\u007f", "\b" # Backspace
|
|
170
|
+
input_buffer = input_buffer[0...-1] unless input_buffer.empty?
|
|
171
|
+
when /[0-9]/
|
|
172
|
+
input_buffer += char
|
|
173
|
+
# Max 2 digits
|
|
174
|
+
input_buffer = input_buffer[-2..-1] if input_buffer.length > 2
|
|
175
|
+
|
|
176
|
+
# If number is within range, select immediately
|
|
177
|
+
number = input_buffer.to_i
|
|
178
|
+
if number > 0 && number <= display_history.length &&
|
|
179
|
+
(number >= 10 || input_buffer.length == 1)
|
|
180
|
+
selected_entry = display_history[number - 1]
|
|
181
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
182
|
+
return selected_entry[:path]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/beniya.rb
CHANGED
|
@@ -5,6 +5,15 @@ require_relative "beniya/config"
|
|
|
5
5
|
require_relative "beniya/config_loader"
|
|
6
6
|
require_relative "beniya/color_helper"
|
|
7
7
|
require_relative "beniya/directory_listing"
|
|
8
|
+
require_relative "beniya/filter_manager"
|
|
9
|
+
require_relative "beniya/selection_manager"
|
|
10
|
+
require_relative "beniya/file_operations"
|
|
11
|
+
require_relative "beniya/bookmark_manager"
|
|
12
|
+
require_relative "beniya/bookmark"
|
|
13
|
+
require_relative "beniya/zoxide_integration"
|
|
14
|
+
require_relative "beniya/dialog_renderer"
|
|
15
|
+
require_relative "beniya/text_utils"
|
|
16
|
+
require_relative "beniya/logger"
|
|
8
17
|
require_relative "beniya/keybind_handler"
|
|
9
18
|
require_relative "beniya/file_preview"
|
|
10
19
|
require_relative "beniya/terminal_ui"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: beniya
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- masisz
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2025-10-
|
|
10
|
+
date: 2025-10-27 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: io-console
|
|
@@ -129,16 +129,24 @@ files:
|
|
|
129
129
|
- lib/beniya.rb
|
|
130
130
|
- lib/beniya/application.rb
|
|
131
131
|
- lib/beniya/bookmark.rb
|
|
132
|
+
- lib/beniya/bookmark_manager.rb
|
|
132
133
|
- lib/beniya/color_helper.rb
|
|
133
134
|
- lib/beniya/config.rb
|
|
134
135
|
- lib/beniya/config_loader.rb
|
|
136
|
+
- lib/beniya/dialog_renderer.rb
|
|
135
137
|
- lib/beniya/directory_listing.rb
|
|
136
138
|
- lib/beniya/file_opener.rb
|
|
139
|
+
- lib/beniya/file_operations.rb
|
|
137
140
|
- lib/beniya/file_preview.rb
|
|
141
|
+
- lib/beniya/filter_manager.rb
|
|
138
142
|
- lib/beniya/health_checker.rb
|
|
139
143
|
- lib/beniya/keybind_handler.rb
|
|
144
|
+
- lib/beniya/logger.rb
|
|
145
|
+
- lib/beniya/selection_manager.rb
|
|
140
146
|
- lib/beniya/terminal_ui.rb
|
|
147
|
+
- lib/beniya/text_utils.rb
|
|
141
148
|
- lib/beniya/version.rb
|
|
149
|
+
- lib/beniya/zoxide_integration.rb
|
|
142
150
|
- publish_gem.zsh
|
|
143
151
|
- test_delete/test1.txt
|
|
144
152
|
- test_delete/test2.txt
|