rufio 0.80.0 → 0.82.0

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.
@@ -0,0 +1,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'file_operations'
4
+ require_relative 'dialog_renderer'
5
+ require_relative 'logger'
6
+
7
+ module Rufio
8
+ # ファイル操作専用コントローラ
9
+ # KeybindHandler からファイル作成・削除・リネーム・移動・コピー系メソッドを分離し、単一責任原則に準拠
10
+ class FileOperationController
11
+ # Dialog size constants
12
+ CONFIRMATION_DIALOG_WIDTH = 45
13
+ DIALOG_BORDER_HEIGHT = 4
14
+ FILESYSTEM_SYNC_DELAY = 0.01 # 10ms wait for filesystem sync
15
+
16
+ def initialize(directory_listing, file_operations, dialog_renderer, nav_controller, selection_manager)
17
+ @directory_listing = directory_listing
18
+ @file_operations = file_operations
19
+ @dialog_renderer = dialog_renderer
20
+ @nav_controller = nav_controller
21
+ @selection_manager = selection_manager
22
+ @terminal_ui = nil
23
+ end
24
+
25
+ def set_terminal_ui(terminal_ui)
26
+ @terminal_ui = terminal_ui
27
+ end
28
+
29
+ def set_directory_listing(directory_listing)
30
+ @directory_listing = directory_listing
31
+ end
32
+
33
+ # ============================
34
+ # ファイル作成
35
+ # ============================
36
+
37
+ def create_file
38
+ current_path = @directory_listing&.current_path || Dir.pwd
39
+
40
+ filename = @dialog_renderer.show_input_dialog("Create File", "Enter file name:", {
41
+ border_color: "\e[32m",
42
+ title_color: "\e[1;32m",
43
+ content_color: "\e[37m"
44
+ })
45
+
46
+ return false if filename.nil? || filename.empty?
47
+
48
+ result = @file_operations.create_file(current_path, filename)
49
+
50
+ if result.success
51
+ @directory_listing.refresh
52
+ entries = @directory_listing.list_entries
53
+ new_file_index = entries.find_index { |entry| entry[:name] == filename }
54
+ @nav_controller.select_index(new_file_index) if new_file_index
55
+ end
56
+
57
+ @terminal_ui&.refresh_display
58
+ result.success
59
+ end
60
+
61
+ def create_directory
62
+ current_path = @directory_listing&.current_path || Dir.pwd
63
+
64
+ dirname = @dialog_renderer.show_input_dialog("Create Directory", "Enter directory name:", {
65
+ border_color: "\e[34m",
66
+ title_color: "\e[1;34m",
67
+ content_color: "\e[37m"
68
+ })
69
+
70
+ return false if dirname.nil? || dirname.empty?
71
+
72
+ result = @file_operations.create_directory(current_path, dirname)
73
+
74
+ if result.success
75
+ @directory_listing.refresh
76
+ entries = @directory_listing.list_entries
77
+ new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
78
+ @nav_controller.select_index(new_dir_index) if new_dir_index
79
+ end
80
+
81
+ @terminal_ui&.refresh_display
82
+ result.success
83
+ end
84
+
85
+ # ============================
86
+ # リネーム
87
+ # ============================
88
+
89
+ # @param entry [Hash] 対象エントリ(current_entry を呼び出し元で渡す)
90
+ def rename_current_file(entry)
91
+ return false unless entry
92
+
93
+ current_name = entry[:name]
94
+ current_path = @directory_listing&.current_path || Dir.pwd
95
+
96
+ new_name = @dialog_renderer.show_input_dialog("Rename: #{current_name}", "Enter new name:", {
97
+ border_color: "\e[33m",
98
+ title_color: "\e[1;33m",
99
+ content_color: "\e[37m"
100
+ })
101
+
102
+ return false if new_name.nil? || new_name.empty?
103
+
104
+ result = @file_operations.rename(current_path, current_name, new_name)
105
+
106
+ if result.success
107
+ @directory_listing.refresh
108
+ entries = @directory_listing.list_entries
109
+ new_index = entries.find_index { |e| e[:name] == new_name }
110
+ @nav_controller.select_index(new_index) if new_index
111
+ end
112
+
113
+ @terminal_ui&.refresh_display
114
+ result.success
115
+ end
116
+
117
+ # ============================
118
+ # 削除
119
+ # ============================
120
+
121
+ # @param entry [Hash] 削除対象エントリ
122
+ # @param get_active_entries [Proc] アクティブエントリ取得ラムダ
123
+ def delete_current_file_with_confirmation(entry, get_active_entries)
124
+ return false if entry.nil?
125
+ return false if entry[:name] == '..'
126
+
127
+ current_path = @directory_listing&.current_path || Dir.pwd
128
+
129
+ # 選択アイテムがある場合はそちらを優先
130
+ unless @selection_manager.empty?
131
+ source_path = @selection_manager.source_directory || current_path
132
+ if show_delete_confirmation(@selection_manager.count, source_path)
133
+ result = @file_operations.delete(@selection_manager.selected_items, source_path)
134
+ @selection_manager.clear
135
+ @directory_listing.refresh if @directory_listing
136
+ @terminal_ui&.refresh_display
137
+ return result.success
138
+ else
139
+ return false
140
+ end
141
+ end
142
+
143
+ current_name = entry[:name]
144
+ is_directory = entry[:type] == :directory
145
+
146
+ type_text = is_directory ? 'directory' : 'file'
147
+ content_lines = [
148
+ '',
149
+ "Delete this #{type_text}?",
150
+ " #{current_name}",
151
+ '',
152
+ ' [Y]es - Delete',
153
+ ' [N]o - Cancel',
154
+ ''
155
+ ]
156
+
157
+ title = 'Confirm Delete'
158
+ width = [50, current_name.length + 10].max
159
+ height = content_lines.length + 4
160
+
161
+ confirmed = false
162
+ show_overlay_dialog(title, content_lines, {
163
+ width: width,
164
+ height: height,
165
+ border_color: "\e[31m",
166
+ title_color: "\e[1;31m",
167
+ content_color: "\e[37m"
168
+ }) do
169
+ loop do
170
+ input = STDIN.getch.downcase
171
+ case input
172
+ when 'y'
173
+ confirmed = true
174
+ break
175
+ when 'n', "\e"
176
+ confirmed = false
177
+ break
178
+ end
179
+ end
180
+ nil
181
+ end
182
+
183
+ return false unless confirmed
184
+
185
+ result = @file_operations.delete([current_name], current_path)
186
+
187
+ if result.success
188
+ @directory_listing.refresh
189
+ entries = get_active_entries.call
190
+ idx = @nav_controller.current_index
191
+ @nav_controller.select_index([[idx, 0].max, [entries.length - 1, 0].max].min)
192
+ end
193
+
194
+ @terminal_ui&.refresh_display
195
+ result.success
196
+ end
197
+
198
+ def delete_selected_files
199
+ return false if @selection_manager.empty?
200
+
201
+ if show_delete_confirmation(@selection_manager.count)
202
+ current_path = @directory_listing&.current_path || Dir.pwd
203
+ result = @file_operations.delete(@selection_manager.selected_items, current_path)
204
+ show_deletion_result(result.count, @selection_manager.count, result.errors)
205
+ @selection_manager.clear
206
+ @directory_listing.refresh if @directory_listing
207
+ true
208
+ else
209
+ false
210
+ end
211
+ end
212
+
213
+ # ============================
214
+ # 移動・コピー
215
+ # ============================
216
+
217
+ def move_selected_to_current
218
+ return false if @selection_manager.empty?
219
+
220
+ current_path = @directory_listing&.current_path || Dir.pwd
221
+ source_path = @selection_manager.source_directory || current_path
222
+
223
+ if show_move_confirmation(@selection_manager.count, source_path, current_path)
224
+ @file_operations.move(@selection_manager.selected_items, source_path, current_path)
225
+ @selection_manager.clear
226
+ @directory_listing.refresh if @directory_listing
227
+ true
228
+ else
229
+ false
230
+ end
231
+ end
232
+
233
+ def copy_selected_to_current
234
+ return false if @selection_manager.empty?
235
+
236
+ current_path = @directory_listing&.current_path || Dir.pwd
237
+ source_path = @selection_manager.source_directory || current_path
238
+
239
+ if show_copy_confirmation(@selection_manager.count, source_path, current_path)
240
+ @file_operations.copy(@selection_manager.selected_items, source_path, current_path)
241
+ @selection_manager.clear
242
+ @directory_listing.refresh if @directory_listing
243
+ true
244
+ else
245
+ false
246
+ end
247
+ end
248
+
249
+ # ============================
250
+ # 終了確認
251
+ # ============================
252
+
253
+ def show_exit_confirmation
254
+ content_lines = [
255
+ '',
256
+ 'Are you sure you want to exit?',
257
+ '',
258
+ ' [Y]es - Exit',
259
+ ' [N]o - Cancel',
260
+ ''
261
+ ]
262
+
263
+ confirmed = false
264
+ show_overlay_dialog('Exit Confirmation', content_lines, {
265
+ width: CONFIRMATION_DIALOG_WIDTH,
266
+ height: DIALOG_BORDER_HEIGHT + content_lines.length,
267
+ border_color: "\e[33m",
268
+ title_color: "\e[1;33m",
269
+ content_color: "\e[37m"
270
+ }) do
271
+ loop do
272
+ input = STDIN.getch.downcase
273
+ case input
274
+ when 'y'
275
+ confirmed = true
276
+ break
277
+ when 'n', "\e", "\x03"
278
+ confirmed = false
279
+ break
280
+ end
281
+ end
282
+ nil
283
+ end
284
+
285
+ confirmed
286
+ end
287
+
288
+ private
289
+
290
+ def show_overlay_dialog(title, content_lines, options = {}, &block)
291
+ use_overlay = @terminal_ui &&
292
+ @terminal_ui.respond_to?(:screen) &&
293
+ @terminal_ui.respond_to?(:renderer) &&
294
+ @terminal_ui.screen &&
295
+ @terminal_ui.renderer
296
+
297
+ if use_overlay
298
+ @terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
299
+ else
300
+ width = options[:width]
301
+ height = options[:height]
302
+
303
+ unless width && height
304
+ width, height = @dialog_renderer.calculate_dimensions(content_lines, {
305
+ title: title,
306
+ min_width: options[:min_width] || 40,
307
+ max_width: options[:max_width] || 80
308
+ })
309
+ end
310
+
311
+ x, y = @dialog_renderer.calculate_center(width, height)
312
+
313
+ @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
314
+ border_color: options[:border_color] || "\e[37m",
315
+ title_color: options[:title_color] || "\e[1;33m",
316
+ content_color: options[:content_color] || "\e[37m"
317
+ })
318
+
319
+ key = block_given? ? yield : STDIN.getch
320
+
321
+ @dialog_renderer.clear_area(x, y, width, height)
322
+ @terminal_ui&.refresh_display
323
+
324
+ key
325
+ end
326
+ end
327
+
328
+ def show_delete_confirmation(count, source_path = nil)
329
+ title = 'Delete Confirmation'
330
+ source_display = source_path ? shorten_path(source_path, 35) : ''
331
+
332
+ content_lines = [
333
+ '',
334
+ "Delete #{count} item(s)?",
335
+ '',
336
+ ]
337
+ content_lines += ["From: #{source_display}", ''] if source_path
338
+ content_lines += [' [Y]es - Delete', ' [N]o - Cancel', '']
339
+
340
+ dialog_width = CONFIRMATION_DIALOG_WIDTH
341
+ dialog_height = DIALOG_BORDER_HEIGHT + content_lines.length
342
+
343
+ print "\a"
344
+
345
+ confirmed = false
346
+ show_overlay_dialog(title, content_lines, {
347
+ width: dialog_width,
348
+ height: dialog_height,
349
+ border_color: "\e[31m",
350
+ title_color: "\e[1;31m",
351
+ content_color: "\e[37m"
352
+ }) do
353
+ loop do
354
+ input = STDIN.getch.downcase
355
+ case input
356
+ when 'y'
357
+ confirmed = true
358
+ break
359
+ when 'n', "\e", "\x03", 'q'
360
+ confirmed = false
361
+ break
362
+ end
363
+ end
364
+ nil
365
+ end
366
+
367
+ confirmed
368
+ end
369
+
370
+ def show_move_confirmation(count, source_path, dest_path)
371
+ source_display = shorten_path(source_path, 35)
372
+ dest_display = shorten_path(dest_path, 35)
373
+
374
+ content_lines = [
375
+ '',
376
+ "Move #{count} item(s)?",
377
+ '',
378
+ "From: #{source_display}",
379
+ "To: #{dest_display}",
380
+ '',
381
+ ' [Y]es - Move',
382
+ ' [N]o - Cancel',
383
+ ''
384
+ ]
385
+
386
+ confirmed = false
387
+ show_overlay_dialog('Move Confirmation', content_lines, {
388
+ width: CONFIRMATION_DIALOG_WIDTH,
389
+ height: DIALOG_BORDER_HEIGHT + content_lines.length,
390
+ border_color: "\e[34m",
391
+ title_color: "\e[1;34m",
392
+ content_color: "\e[37m"
393
+ }) do
394
+ loop do
395
+ input = STDIN.getch.downcase
396
+ case input
397
+ when 'y'
398
+ confirmed = true
399
+ break
400
+ when 'n', "\e", "\x03", 'q'
401
+ confirmed = false
402
+ break
403
+ end
404
+ end
405
+ nil
406
+ end
407
+
408
+ confirmed
409
+ end
410
+
411
+ def show_copy_confirmation(count, source_path, dest_path)
412
+ source_display = shorten_path(source_path, 35)
413
+ dest_display = shorten_path(dest_path, 35)
414
+
415
+ content_lines = [
416
+ '',
417
+ "Copy #{count} item(s)?",
418
+ '',
419
+ "From: #{source_display}",
420
+ "To: #{dest_display}",
421
+ '',
422
+ ' [Y]es - Copy',
423
+ ' [N]o - Cancel',
424
+ ''
425
+ ]
426
+
427
+ confirmed = false
428
+ show_overlay_dialog('Copy Confirmation', content_lines, {
429
+ width: CONFIRMATION_DIALOG_WIDTH,
430
+ height: DIALOG_BORDER_HEIGHT + content_lines.length,
431
+ border_color: "\e[32m",
432
+ title_color: "\e[1;32m",
433
+ content_color: "\e[37m"
434
+ }) do
435
+ loop do
436
+ input = STDIN.getch.downcase
437
+ case input
438
+ when 'y'
439
+ confirmed = true
440
+ break
441
+ when 'n', "\e", "\x03", 'q'
442
+ confirmed = false
443
+ break
444
+ end
445
+ end
446
+ nil
447
+ end
448
+
449
+ confirmed
450
+ end
451
+
452
+ def show_deletion_result(success_count, total_count, error_messages = [])
453
+ has_errors = !error_messages.empty?
454
+ dialog_width = has_errors ? 50 : 35
455
+ dialog_height = has_errors ? [8 + error_messages.length, 15].min : 6
456
+
457
+ if success_count == total_count && !has_errors
458
+ border_color = "\e[32m"
459
+ title_color = "\e[1;32m"
460
+ title = 'Delete Complete'
461
+ message = "Deleted #{success_count} item(s)"
462
+ else
463
+ border_color = "\e[33m"
464
+ title_color = "\e[1;33m"
465
+ title = 'Delete Result'
466
+ failed_count = total_count - success_count
467
+ message = "#{success_count} deleted, #{failed_count} failed"
468
+ end
469
+
470
+ content_lines = ['', message]
471
+
472
+ if has_errors
473
+ content_lines << ''
474
+ content_lines << 'Error details:'
475
+ error_messages.each { |error| content_lines << " #{error}" }
476
+ end
477
+
478
+ content_lines << ''
479
+
480
+ show_overlay_dialog(title, content_lines, {
481
+ width: dialog_width,
482
+ height: dialog_height,
483
+ border_color: border_color,
484
+ title_color: title_color,
485
+ content_color: "\e[37m"
486
+ }) do
487
+ STDIN.getch
488
+ nil
489
+ end
490
+ end
491
+
492
+ def shorten_path(path, max_length)
493
+ return path if path.length <= max_length
494
+ "...#{path[-(max_length - 3)..-1]}"
495
+ end
496
+ end
497
+ end