beniya 0.6.0 → 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/application.rb +4 -1
- 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/file_preview.rb +11 -3
- data/lib/beniya/filter_manager.rb +114 -0
- data/lib/beniya/keybind_handler.rb +211 -671
- data/lib/beniya/logger.rb +103 -0
- data/lib/beniya/selection_manager.rb +79 -0
- data/lib/beniya/terminal_ui.rb +54 -25
- 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
|
data/lib/beniya/application.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Beniya
|
|
4
4
|
class Application
|
|
5
|
+
# Error display constants
|
|
6
|
+
BACKTRACE_LINES = 5 # Number of backtrace lines to show
|
|
7
|
+
|
|
5
8
|
def initialize(start_directory = Dir.pwd)
|
|
6
9
|
@start_directory = File.expand_path(start_directory)
|
|
7
10
|
# Load configuration including language settings
|
|
@@ -22,7 +25,7 @@ module Beniya
|
|
|
22
25
|
puts "\n\n#{ConfigLoader.message('app.interrupted')}"
|
|
23
26
|
rescue StandardError => e
|
|
24
27
|
puts "\n#{ConfigLoader.message('app.error_occurred')}: #{e.message}"
|
|
25
|
-
puts e.backtrace.first(
|
|
28
|
+
puts e.backtrace.first(BACKTRACE_LINES).join("\n")
|
|
26
29
|
end
|
|
27
30
|
end
|
|
28
31
|
end
|
|
@@ -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
|
data/lib/beniya/file_preview.rb
CHANGED
|
@@ -6,6 +6,13 @@ module Beniya
|
|
|
6
6
|
DEFAULT_MAX_LINES = 50
|
|
7
7
|
MAX_LINE_LENGTH = 500
|
|
8
8
|
|
|
9
|
+
# Binary detection constants
|
|
10
|
+
BINARY_SAMPLE_SIZE = 512
|
|
11
|
+
PRINTABLE_CHAR_THRESHOLD = 32
|
|
12
|
+
CONTROL_CHAR_TAB = 9
|
|
13
|
+
CONTROL_CHAR_NEWLINE = 10
|
|
14
|
+
CONTROL_CHAR_CARRIAGE_RETURN = 13
|
|
15
|
+
|
|
9
16
|
def initialize
|
|
10
17
|
# future: hold syntax highlight settings etc.
|
|
11
18
|
end
|
|
@@ -19,7 +26,7 @@ module Beniya
|
|
|
19
26
|
|
|
20
27
|
begin
|
|
21
28
|
# binary file detection
|
|
22
|
-
sample = File.binread(file_path, [file_size,
|
|
29
|
+
sample = File.binread(file_path, [file_size, BINARY_SAMPLE_SIZE].min)
|
|
23
30
|
return binary_response(file_path) if binary_file?(sample)
|
|
24
31
|
|
|
25
32
|
# process as text file
|
|
@@ -44,8 +51,9 @@ module Beniya
|
|
|
44
51
|
|
|
45
52
|
def binary_file?(sample)
|
|
46
53
|
return false if sample.empty?
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
|
|
55
|
+
allowed_control_chars = [CONTROL_CHAR_TAB, CONTROL_CHAR_NEWLINE, CONTROL_CHAR_CARRIAGE_RETURN]
|
|
56
|
+
binary_chars = sample.bytes.count { |byte| byte < PRINTABLE_CHAR_THRESHOLD && !allowed_control_chars.include?(byte) }
|
|
49
57
|
(binary_chars.to_f / sample.bytes.length) > BINARY_THRESHOLD
|
|
50
58
|
end
|
|
51
59
|
|