├── .gitignore ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin └── rfd ├── lib ├── rfd.rb └── rfd │ ├── commands.rb │ ├── item.rb │ ├── logging.rb │ ├── reline_ext.rb │ └── windows.rb ├── rfd.gemspec └── spec ├── controller_spec.rb ├── spec_helper.rb ├── support └── capture_helper.rb └── testdir ├── .file1 ├── .file2 ├── .file3 ├── .link1 ├── dir1 └── .keep ├── dir2 └── .keep ├── dir3 └── .keep ├── dirlink1 ├── file1 ├── file2 ├── file3 ├── gz1.tar.gz ├── link1 ├── link2 └── zip1.zip /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.log 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | # Specify your gem's dependencies in rfd.gemspec 5 | gemspec 6 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Akira Matsuda 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rfd (Ruby on Files & Directories) 2 | 3 | rfd is a terminal-based filesystem explorer, inspired by the legendary freesoft MS-DOS filer, "FD". 4 | 5 | ## Installation 6 | 7 | % gem install rfd 8 | 9 | ## Requirements 10 | 11 | * Ruby 2.0, Ruby 2.1 12 | * NCurses 13 | 14 | ## Tested environments 15 | 16 | Mac OS X Mountain Lion, Mac OS X Lion, Ubuntu 13.04 17 | 18 | ## Screenshot 19 | 20 | ![screenshot](https://www.evernote.com/shard/s20/sh/a0a275ee-39b5-4ba4-9374-8534f4ee2a24/377c504f45f17a75eb2ea12bd015b6ee/deep/0/rfd_screenshot.png) 21 | 22 | ## Start Me Up 23 | 24 | Open up your terminal and type: 25 | 26 | % rfd 27 | 28 | You can also pass in a starting directory name, which defaults to `.`. 29 | 30 | % rfd ~/src/rails 31 | 32 | ## Commands 33 | 34 | You can send commands to rfd by pressing some chars on your keyboard, just like Vim. 35 | If you're unfamiliar with this sort of command system, I recommend you to play with `vimtutor` before you go any further. 36 | 37 | All available commands in rfd are defined as Ruby methods here. https://github.com/amatsuda/rfd/tree/master/lib/rfd/commands.rb 38 | 39 | ### Changing the current directory 40 | 41 | * ``: cd into the directory where the cursor is on. 42 | * `` (or \ on your keyboard, probably?): Go up to the upper directory (cd ..). 43 | * `-`: Get back to where you once belonged (popd). 44 | * `@`: cd to a directory given via the command-line window. 45 | 46 | ### Moving the cursor 47 | 48 | * `j`: Move down. 49 | * `k`: Move up. 50 | * `h`: Move left. At the leftmost column, move to the right end column at the previous page. 51 | * `l`: Move right. At the rightmost column, move to the left end column at the next page. 52 | 53 | ### The {count} parameter 54 | 55 | Some commands such as `j` or `k` take a number parameter called {count}. For passing a {count} parameter, just type in a number prior to the command. 56 | For example, `3j` moves the cursor to 3 lines below, and `999k` will take your cursor to 999 lines above. 57 | 58 | ### Jumping the cursor 59 | 60 | * `H`: Move to the top of the current page. 61 | * `M`: Move to the middle of the current page. 62 | * `L`: Move to the bottom of the current page. 63 | 64 | ### Switching the page 65 | 66 | * `ctrl-n, ctrl-f`: Move to the top of the next page. 67 | * `ctrl-p, ctrl-b`: Move to the top of the previous page. 68 | * `g`: Move to the top of the first page. 69 | * `G`: Move to the bottom of the last page. 70 | 71 | ### Finding a file / directory 72 | 73 | You can find a file by typing the first letter of it immediately after the find commands. 74 | 75 | * `f{char}`: Move to the next file / directory of which name starts with the given char. 76 | * `F{char}`: Move to the previous file / directory of which name starts with the given char. 77 | * `n`: Repeat the last `f` or `F`. 78 | 79 | ### Searching, sorting 80 | 81 | For commands like these that require a parameter string, type the parameter in the command line at the bottom of the screen, and press \. 82 | 83 | * `/`: Grep the current directory with the given parameter. The parameter will be interpreted as Ruby Regexp (e.g. `.*\.rb$`). 84 | * `s`: Sort files / directories in the current directory in the given order. 85 | * (none): by name 86 | * r : reverse order by name 87 | * s, S : order by file size 88 | * sr, Sr: reverse order by file size 89 | * t : order by mtime 90 | * tr : reverse order by mtime 91 | * c : order by ctime 92 | * cr : reverse order by ctime 93 | * u : order by atime 94 | * ur : reverse order by atime 95 | * e : order by extname 96 | * er : reverse order by extname 97 | 98 | ### Marking files / directories 99 | 100 | You can send a command to the file / directory on which the cursor is on. Or, you can send a command to multiple files / directories at once by marking them first. 101 | The mark is drawn as a `*` char on the left of each file / directory name. 102 | 103 | * ``: Mark / unmark current file / directory. 104 | * `ctrl-a`: Mark / unmark all file / directories in the current directory. 105 | 106 | ### Manipulating files / directories 107 | 108 | As stated above, you can send a command to one or more files / directories. In this document, the term "selected items" means "(the marked files / directories) || (the file / directory on which the cursor is on)". 109 | 110 | * `c`: Copy selected items (cp). 111 | * `m`: Move selected items (mv). 112 | * `d`: Move selected items into the Trash. 113 | * `D`: Delete selected items. 114 | * `r`: Rename selected items. This command takes a sed-like argument separated by a `/`. For example, changing all .html files' extension to .html.erb could be done by `\.html$/.html.erb`. 115 | 116 | ### Yank and Paste 117 | 118 | `y` & `p` works just like Windows-c & Windows-v on explorer.exe. 119 | 120 | * `y`: Yank selected items. 121 | * `p`: Paste yanked items into the directory on which the cursor is, or into the current directory. 122 | 123 | ### Creating files / directories 124 | 125 | * `t`: Create a new file (touch). 126 | * `K`: Creat a new directory (mkdir). 127 | * `S`: Create new symlink to the current file / directory (ln -s). 128 | 129 | ### Attributes 130 | 131 | * `a`: Change permission of selected items (chmod). Takes chmod-like argument such as `g+w`, `755`. 132 | * `w`: Change the owner of of selected items (chown). Takes chown-like argument such as `alice`, `nobody:nobody`. 133 | 134 | ### Viewing, Editing, Opening 135 | 136 | * ``: View current file with the system $VIEWER such as `less`. 137 | * `v`: View current file with the system $VIEWER such as `less`. 138 | * `e`: Edit current file with the system $EDITOR such as `vim`. 139 | * `o`: Send the `open` command. 140 | 141 | ### Manipulating archives 142 | 143 | * `u`: Unarchive .zip, .gz, or .tar.gz file into the current directory. 144 | * `z`: Archive selected items into a .zip file with the given name. 145 | 146 | ### Handling .zip files 147 | 148 | You can `cd` into a .zip file as if it's just a directory, then unarchive selected items, view files in it, and even create new files or edit files in the archive. 149 | 150 | ### Splitting columns 151 | 152 | * `ctrl-w`: Change the window split size to the {count} value (e.g. `4` to split the window into 4 columns). The default number of columns is 2. 153 | 154 | ### Using mouse 155 | 156 | Mouse is available if your terminal supports it. You can move the cursor by clicking on a file / directory. Double clicking on a file / directory is equivalent to pressing \ on it. 157 | 158 | ### Misc 159 | 160 | * `ctrl-l`: Refresh the whole screen. 161 | * `C`: Copy selected items' paths to the clipboard. 162 | * `O`: Open a new terminal window at the current directory. 163 | * `!`: Execute a shell command. 164 | * `q`: Quit the app. 165 | 166 | ## How to manually execute a command, or how the commands are executed 167 | 168 | By pressing `:`, you can enter the command-line mode. Any string given in the command line after `:` will be executed as Ruby method call in the `Controller` instance. 169 | For instance, `:j` brings your cursor down, `:mkdir foo` makes a directory named "foo". And `:q!` of course works as you might expect, since `q!` method is implemented so. 170 | 171 | ## Contributing 172 | 173 | Send me your pull requests here. https://github.com/amatsuda/rfd 174 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'bundler' 3 | Bundler::GemHelper.install_tasks 4 | require "bundler/gem_tasks" 5 | 6 | require 'rspec/core' 7 | require 'rspec/core/rake_task' 8 | 9 | RSpec::Core::RakeTask.new(:spec) do |spec| 10 | spec.pattern = FileList['spec/**/*_spec.rb'] 11 | end 12 | 13 | task :default => :spec 14 | -------------------------------------------------------------------------------- /bin/rfd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | require File.expand_path('../../lib/rfd', __FILE__) 4 | require 'optparse' 5 | 6 | options = ARGV.getopts 'l:' 7 | 8 | rfd = Rfd.start ARGV[0] || '.', log: options['l'] 9 | rfd.run 10 | -------------------------------------------------------------------------------- /lib/rfd.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'curses' 3 | require 'fileutils' 4 | require 'time' 5 | require 'tmpdir' 6 | require 'rubygems/package' 7 | require 'zip' 8 | require 'zip/filesystem' 9 | require 'reline' 10 | require_relative 'rfd/commands' 11 | require_relative 'rfd/item' 12 | require_relative 'rfd/windows' 13 | require_relative 'rfd/logging' 14 | require_relative 'rfd/reline_ext' 15 | 16 | module Rfd 17 | VERSION = Gem.loaded_specs['rfd'] ? Gem.loaded_specs['rfd'].version.to_s : '0' 18 | 19 | # :nodoc: 20 | def self.init_curses 21 | Curses.init_screen 22 | Curses.raw 23 | Curses.noecho 24 | Curses.curs_set 0 25 | Curses.stdscr.keypad = true 26 | Curses.start_color 27 | 28 | [Curses::COLOR_WHITE, Curses::COLOR_CYAN, Curses::COLOR_MAGENTA, Curses::COLOR_GREEN, Curses::COLOR_RED].each do |c| 29 | Curses.init_pair c, c, Curses::COLOR_BLACK 30 | end 31 | 32 | Curses.mousemask Curses::BUTTON1_CLICKED | Curses::BUTTON1_DOUBLE_CLICKED 33 | end 34 | 35 | # Start the app here! 36 | # 37 | # ==== Parameters 38 | # * +dir+ - The initial directory. 39 | def self.start(dir = '.', log: nil) 40 | Rfd.log_to log if log 41 | 42 | init_curses 43 | Rfd::Window.draw_borders 44 | Curses.stdscr.noutrefresh 45 | rfd = Rfd::Controller.new 46 | rfd.cd dir 47 | Curses.doupdate 48 | rfd 49 | end 50 | 51 | class Controller 52 | include Rfd::Commands 53 | 54 | attr_reader :header_l, :header_r, :main, :command_line, :items, :displayed_items, :current_row, :current_page, :current_dir, :current_zip 55 | 56 | # :nodoc: 57 | def initialize 58 | @main = MainWindow.new 59 | @header_l = HeaderLeftWindow.new 60 | @header_r = HeaderRightWindow.new 61 | @command_line = CommandLineWindow.new 62 | @debug = DebugWindow.new if ENV['DEBUG'] 63 | @direction, @dir_history, @last_command, @times, @yanked_items = nil, [], nil, nil, nil 64 | end 65 | 66 | # The main loop. 67 | def run 68 | loop do 69 | begin 70 | number_pressed = false 71 | ret = case (c = Curses.getch) 72 | when 10, 13 # enter, return 73 | enter 74 | when 27 # ESC 75 | q 76 | when ' ' # space 77 | space 78 | when 127 # DEL 79 | del 80 | when Curses::KEY_DOWN 81 | j 82 | when Curses::KEY_UP 83 | k 84 | when Curses::KEY_LEFT 85 | h 86 | when Curses::KEY_RIGHT 87 | l 88 | when Curses::KEY_CTRL_A..Curses::KEY_CTRL_Z 89 | chr = ((c - 1 + 65) ^ 0b0100000).chr 90 | public_send "ctrl_#{chr}" if respond_to?("ctrl_#{chr}") 91 | when ?0..?9 92 | public_send c 93 | number_pressed = true 94 | when ?!..?~ 95 | if respond_to? c 96 | public_send c 97 | else 98 | debug "key: #{c}" if ENV['DEBUG'] 99 | end 100 | when Curses::KEY_MOUSE 101 | if (mouse_event = Curses.getmouse) 102 | case mouse_event.bstate 103 | when Curses::BUTTON1_CLICKED 104 | click y: mouse_event.y, x: mouse_event.x 105 | when Curses::BUTTON1_DOUBLE_CLICKED 106 | double_click y: mouse_event.y, x: mouse_event.x 107 | end 108 | end 109 | else 110 | debug "key: #{c}" if ENV['DEBUG'] 111 | end 112 | Curses.doupdate if ret 113 | @times = nil unless number_pressed 114 | rescue StopIteration 115 | raise 116 | rescue => e 117 | Rfd.logger.error e if Rfd.logger 118 | command_line.show_error e.to_s 119 | raise if ENV['DEBUG'] 120 | end 121 | end 122 | ensure 123 | Curses.close_screen 124 | end 125 | 126 | # Change the number of columns in the main window. 127 | def spawn_panes(num) 128 | main.number_of_panes = num 129 | @current_row = @current_page = 0 130 | end 131 | 132 | # Number of times to repeat the next command. 133 | def times 134 | (@times || 1).to_i 135 | end 136 | 137 | # The file or directory on which the cursor is on. 138 | def current_item 139 | items[current_row] 140 | end 141 | 142 | # * marked files and directories. 143 | def marked_items 144 | items.select(&:marked?) 145 | end 146 | 147 | # Marked files and directories or Array(the current file or directory). 148 | # 149 | # . and .. will not be included. 150 | def selected_items 151 | ((m = marked_items).any? ? m : Array(current_item)).reject {|i| %w(. ..).include? i.name} 152 | end 153 | 154 | # Move the cursor to specified row. 155 | # 156 | # The main window and the headers will be updated reflecting the displayed files and directories. 157 | # The row number can be out of range of the current page. 158 | def move_cursor(row = nil) 159 | if row 160 | if (prev_item = items[current_row]) 161 | main.draw_item prev_item 162 | end 163 | page = row / max_items 164 | switch_page page if page != current_page 165 | main.activate_pane row / maxy 166 | @current_row = row 167 | else 168 | @current_row = 0 169 | end 170 | 171 | item = items[current_row] 172 | main.draw_item item, current: true 173 | main.display current_page 174 | 175 | header_l.draw_current_file_info item 176 | @current_row 177 | end 178 | 179 | # Change the current directory. 180 | def cd(dir = '~', pushd: true) 181 | dir = load_item path: expand_path(dir) unless dir.is_a? Item 182 | unless dir.zip? 183 | Dir.chdir dir 184 | @current_zip = nil 185 | else 186 | @current_zip = dir 187 | end 188 | @dir_history << current_dir if current_dir && pushd 189 | @current_dir, @current_page, @current_row = dir, 0, nil 190 | main.activate_pane 0 191 | ls 192 | @current_dir 193 | end 194 | 195 | # cd to the previous directory. 196 | def popd 197 | cd @dir_history.pop, pushd: false if @dir_history.any? 198 | end 199 | 200 | # Fetch files from current directory. 201 | # Then update each windows reflecting the newest information. 202 | def ls 203 | fetch_items_from_filesystem_or_zip 204 | sort_items_according_to_current_direction 205 | 206 | @current_page ||= 0 207 | draw_items 208 | move_cursor (current_row ? [current_row, items.size - 1].min : nil) 209 | 210 | draw_marked_items 211 | draw_total_items 212 | true 213 | end 214 | 215 | # Sort the whole files and directories in the current directory, then refresh the screen. 216 | # 217 | # ==== Parameters 218 | # * +direction+ - Sort order in a String. 219 | # nil : order by name 220 | # r : reverse order by name 221 | # s, S : order by file size 222 | # sr, Sr: reverse order by file size 223 | # t : order by mtime 224 | # tr : reverse order by mtime 225 | # c : order by ctime 226 | # cr : reverse order by ctime 227 | # u : order by atime 228 | # ur : reverse order by atime 229 | # e : order by extname 230 | # er : reverse order by extname 231 | def sort(direction = nil) 232 | @direction, @current_page = direction, 0 233 | sort_items_according_to_current_direction 234 | switch_page 0 235 | move_cursor 0 236 | end 237 | 238 | # Change the file permission of the selected files and directories. 239 | # 240 | # ==== Parameters 241 | # * +mode+ - Unix chmod string (e.g. +w, g-r, 755, 0644) 242 | def chmod(mode = nil) 243 | return unless mode 244 | begin 245 | Integer mode 246 | mode = Integer mode.size == 3 ? "0#{mode}" : mode 247 | rescue ArgumentError 248 | end 249 | FileUtils.chmod mode, selected_items.map(&:path) 250 | ls 251 | end 252 | 253 | # Change the file owner of the selected files and directories. 254 | # 255 | # ==== Parameters 256 | # * +user_and_group+ - user name and group name separated by : (e.g. alice, nobody:nobody, :admin) 257 | def chown(user_and_group) 258 | return unless user_and_group 259 | user, group = user_and_group.split(':').map {|s| s == '' ? nil : s} 260 | FileUtils.chown user, group, selected_items.map(&:path) 261 | ls 262 | end 263 | 264 | # Fetch files from current directory or current .zip file. 265 | def fetch_items_from_filesystem_or_zip 266 | unless in_zip? 267 | @items = Dir.foreach(current_dir).map {|fn| 268 | load_item dir: current_dir, name: fn 269 | }.to_a.partition {|i| %w(. ..).include? i.name}.flatten 270 | else 271 | @items = [load_item(dir: current_dir, name: '.', stat: File.stat(current_dir)), 272 | load_item(dir: current_dir, name: '..', stat: File.stat(File.dirname(current_dir)))] 273 | zf = Zip::File.new current_dir 274 | zf.each {|entry| 275 | next if entry.name_is_directory? 276 | stat = zf.file.stat entry.name 277 | @items << load_item(dir: current_dir, name: entry.name, stat: stat) 278 | } 279 | end 280 | end 281 | 282 | # Focus at the first file or directory of which name starts with the given String. 283 | def find(str) 284 | index = items.index {|i| i.index > current_row && i.name.start_with?(str)} || items.index {|i| i.name.start_with? str} 285 | move_cursor index if index 286 | end 287 | 288 | # Focus at the last file or directory of which name starts with the given String. 289 | def find_reverse(str) 290 | index = items.reverse.index {|i| i.index < current_row && i.name.start_with?(str)} || items.reverse.index {|i| i.name.start_with? str} 291 | move_cursor items.size - index - 1 if index 292 | end 293 | 294 | # Height of the currently active pane. 295 | def maxy 296 | main.maxy 297 | end 298 | 299 | # Number of files or directories that the current main window can show in a page. 300 | def max_items 301 | main.max_items 302 | end 303 | 304 | # Update the main window with the loaded files and directories. Also update the header. 305 | def draw_items 306 | main.newpad items 307 | @displayed_items = items[current_page * max_items, max_items] 308 | main.display current_page 309 | header_l.draw_path_and_page_number path: current_dir.path, current: current_page + 1, total: total_pages 310 | end 311 | 312 | # Sort the loaded files and directories in already given sort order. 313 | def sort_items_according_to_current_direction 314 | case @direction 315 | when nil 316 | @items = items.shift(2) + items.partition(&:directory?).flat_map(&:sort) 317 | when 'r' 318 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort.reverse} 319 | when 'S', 's' 320 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort_by {|i| -i.size}} 321 | when 'Sr', 'sr' 322 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort_by(&:size)} 323 | when 't' 324 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort {|x, y| y.mtime <=> x.mtime}} 325 | when 'tr' 326 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort_by(&:mtime)} 327 | when 'c' 328 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort {|x, y| y.ctime <=> x.ctime}} 329 | when 'cr' 330 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort_by(&:ctime)} 331 | when 'u' 332 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort {|x, y| y.atime <=> x.atime}} 333 | when 'ur' 334 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort_by(&:atime)} 335 | when 'e' 336 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort {|x, y| y.extname <=> x.extname}} 337 | when 'er' 338 | @items = items.shift(2) + items.partition(&:directory?).flat_map {|arr| arr.sort_by(&:extname)} 339 | end 340 | items.each.with_index {|item, index| item.index = index} 341 | end 342 | 343 | # Search files and directories from the current directory, and update the screen. 344 | # 345 | # * +pattern+ - Search pattern against file names in Ruby Regexp string. 346 | # 347 | # === Example 348 | # 349 | # a : Search files that contains the letter "a" in their file name 350 | # .*\.pdf$ : Search PDF files 351 | def grep(pattern = '.*') 352 | regexp = Regexp.new(pattern) 353 | fetch_items_from_filesystem_or_zip 354 | @items = items.shift(2) + items.select {|i| i.name =~ regexp} 355 | sort_items_according_to_current_direction 356 | draw_items 357 | draw_total_items 358 | switch_page 0 359 | move_cursor 0 360 | end 361 | 362 | # Copy selected files and directories to the destination. 363 | def cp(dest) 364 | unless in_zip? 365 | src = (m = marked_items).any? ? m.map(&:path) : current_item 366 | FileUtils.cp_r src, expand_path(dest) 367 | else 368 | raise 'cping multiple items in .zip is not supported.' if selected_items.size > 1 369 | Zip::File.open(current_zip) do |zip| 370 | entry = zip.find_entry(selected_items.first.name).dup 371 | entry.name, entry.name_length = dest, dest.size 372 | zip.instance_variable_get(:@entry_set) << entry 373 | end 374 | end 375 | ls 376 | end 377 | 378 | # Move selected files and directories to the destination. 379 | def mv(dest) 380 | unless in_zip? 381 | src = (m = marked_items).any? ? m.map(&:path) : current_item 382 | FileUtils.mv src, expand_path(dest) 383 | else 384 | raise 'mving multiple items in .zip is not supported.' if selected_items.size > 1 385 | rename "#{selected_items.first.name}/#{dest}" 386 | end 387 | ls 388 | end 389 | 390 | # Rename selected files and directories. 391 | # 392 | # ==== Parameters 393 | # * +pattern+ - new filename, or a shash separated Regexp like string 394 | def rename(pattern) 395 | from, to = pattern.sub(/^\//, '').sub(/\/$/, '').split '/' 396 | if to.nil? 397 | from, to = current_item.name, from 398 | else 399 | from = Regexp.new from 400 | end 401 | unless in_zip? 402 | selected_items.each do |item| 403 | name = item.name.gsub from, to 404 | FileUtils.mv item, current_dir.join(name) if item.name != name 405 | end 406 | else 407 | Zip::File.open(current_zip) do |zip| 408 | selected_items.each do |item| 409 | name = item.name.gsub from, to 410 | zip.rename item.name, name 411 | end 412 | end 413 | end 414 | ls 415 | end 416 | 417 | # Soft delete selected files and directories. 418 | # 419 | # If the OS is not OSX, performs the same as `delete` command. 420 | def trash 421 | unless in_zip? 422 | if osx? 423 | FileUtils.mv selected_items.map(&:path), File.expand_path('~/.Trash/') 424 | else 425 | #TODO support other OS 426 | FileUtils.rm_rf selected_items.map(&:path) 427 | end 428 | else 429 | return unless ask %Q[Trashing zip entries is not supported. Actually the files will be deleted. Are you sure want to proceed? (y/n)] 430 | delete 431 | end 432 | @current_row -= selected_items.count {|i| i.index <= current_row} 433 | ls 434 | end 435 | 436 | # Delete selected files and directories. 437 | def delete 438 | unless in_zip? 439 | FileUtils.rm_rf selected_items.map(&:path) 440 | else 441 | Zip::File.open(current_zip) do |zip| 442 | zip.select {|e| selected_items.map(&:name).include? e.to_s}.each do |entry| 443 | if entry.name_is_directory? 444 | zip.dir.delete entry.to_s 445 | else 446 | zip.file.delete entry.to_s 447 | end 448 | end 449 | end 450 | end 451 | @current_row -= selected_items.count {|i| i.index <= current_row} 452 | ls 453 | end 454 | 455 | # Create a new directory. 456 | def mkdir(dir) 457 | unless in_zip? 458 | FileUtils.mkdir_p current_dir.join(dir) 459 | else 460 | Zip::File.open(current_zip) do |zip| 461 | zip.dir.mkdir dir 462 | end 463 | end 464 | ls 465 | end 466 | 467 | # Create a new empty file. 468 | def touch(filename) 469 | unless in_zip? 470 | FileUtils.touch current_dir.join(filename) 471 | else 472 | Zip::File.open(current_zip) do |zip| 473 | # zip.file.open(filename, 'w') {|_f| } #HAXX this code creates an unneeded temporary file 474 | zip.instance_variable_get(:@entry_set) << Zip::Entry.new(current_zip, filename) 475 | end 476 | end 477 | 478 | ls 479 | move_cursor items.index {|i| i.name == filename} 480 | end 481 | 482 | # Create a symlink to the current file or directory. 483 | def symlink(name) 484 | FileUtils.ln_s current_item, name 485 | ls 486 | end 487 | 488 | # Change the timestamp of the selected files and directories. 489 | # 490 | # ==== Parameters 491 | # * +timestamp+ - A string that can be parsed with `Time.parse`. Note that this parameter is not compatible with UNIX `touch -t`. 492 | def touch_t(timestamp) 493 | FileUtils.touch selected_items, mtime: Time.parse(timestamp) 494 | ls 495 | end 496 | 497 | # Yank selected file / directory names. 498 | def yank 499 | @yanked_items = selected_items 500 | end 501 | 502 | # Paste yanked files / directories here. 503 | def paste 504 | if @yanked_items 505 | if current_item.directory? 506 | FileUtils.cp_r @yanked_items.map(&:path), current_item 507 | else 508 | @yanked_items.each do |item| 509 | if items.include? item 510 | i = 1 511 | while i += 1 512 | new_item = load_item dir: current_dir, name: "#{item.basename}_#{i}#{item.extname}", stat: item.stat 513 | break unless File.exist? new_item.path 514 | end 515 | FileUtils.cp_r item, new_item 516 | else 517 | FileUtils.cp_r item, current_dir 518 | end 519 | end 520 | end 521 | ls 522 | end 523 | end 524 | 525 | # Copy selected files and directories' path into clipboard on OSX. 526 | def clipboard 527 | IO.popen('pbcopy', 'w') {|f| f << selected_items.map(&:path).join(' ')} if osx? 528 | end 529 | 530 | # Archive selected files and directories into a .zip file. 531 | def zip(zipfile_name) 532 | return unless zipfile_name 533 | zipfile_name += '.zip' unless zipfile_name.end_with? '.zip' 534 | 535 | Zip::File.open(zipfile_name, Zip::File::CREATE) do |zipfile| 536 | selected_items.each do |item| 537 | next if item.symlink? 538 | if item.directory? 539 | Dir[item.join('**/**')].each do |file| 540 | zipfile.add file.sub("#{current_dir}/", ''), file 541 | end 542 | else 543 | zipfile.add item.name, item 544 | end 545 | end 546 | end 547 | ls 548 | end 549 | 550 | # Unarchive .zip and .tar.gz files within selected files and directories into current_directory. 551 | def unarchive 552 | unless in_zip? 553 | zips, gzs = selected_items.partition(&:zip?).tap {|z, others| break [z, *others.partition(&:gz?)]} 554 | zips.each do |item| 555 | FileUtils.mkdir_p current_dir.join(item.basename) 556 | Zip::File.open(item) do |zip| 557 | zip.each do |entry| 558 | FileUtils.mkdir_p File.join(item.basename, File.dirname(entry.to_s)) 559 | zip.extract(entry, File.join(item.basename, entry.to_s)) { true } 560 | end 561 | end 562 | end 563 | gzs.each do |item| 564 | Zlib::GzipReader.open(item) do |gz| 565 | Gem::Package::TarReader.new(gz) do |tar| 566 | dest_dir = current_dir.join (gz.orig_name || item.basename).sub(/\.tar$/, '') 567 | tar.each do |entry| 568 | dest = nil 569 | if entry.full_name == '././@LongLink' 570 | dest = File.join dest_dir, entry.read.strip 571 | next 572 | end 573 | dest ||= File.join dest_dir, entry.full_name 574 | if entry.directory? 575 | FileUtils.mkdir_p dest, mode: entry.header.mode 576 | elsif entry.file? 577 | FileUtils.mkdir_p dest_dir 578 | File.open(dest, 'wb') {|f| f.print entry.read} 579 | FileUtils.chmod entry.header.mode, dest 580 | elsif entry.header.typeflag == '2' # symlink 581 | File.symlink entry.header.linkname, dest 582 | end 583 | unless Dir.exist? dest_dir 584 | FileUtils.mkdir_p dest_dir 585 | File.open(File.join(dest_dir, gz.orig_name || item.basename), 'wb') {|f| f.print gz.read} 586 | end 587 | end 588 | end 589 | end 590 | end 591 | else 592 | Zip::File.open(current_zip) do |zip| 593 | zip.select {|e| selected_items.map(&:name).include? e.to_s}.each do |entry| 594 | FileUtils.mkdir_p File.join(current_zip.dir, current_zip.basename, File.dirname(entry.to_s)) 595 | zip.extract(entry, File.join(current_zip.dir, current_zip.basename, entry.to_s)) { true } 596 | end 597 | end 598 | end 599 | ls 600 | end 601 | 602 | # Current page is the first page? 603 | def first_page? 604 | current_page == 0 605 | end 606 | 607 | # Do we have more pages? 608 | def last_page? 609 | current_page == total_pages - 1 610 | end 611 | 612 | # Number of pages in the current directory. 613 | def total_pages 614 | (items.size - 1) / max_items + 1 615 | end 616 | 617 | # Move to the given page number. 618 | # 619 | # ==== Parameters 620 | # * +page+ - Target page number 621 | def switch_page(page) 622 | main.display (@current_page = page) 623 | @displayed_items = items[current_page * max_items, max_items] 624 | header_l.draw_path_and_page_number path: current_dir.path, current: current_page + 1, total: total_pages 625 | end 626 | 627 | # Update the header information concerning currently marked files or directories. 628 | def draw_marked_items 629 | items = marked_items 630 | header_r.draw_marked_items count: items.size, size: items.inject(0) {|sum, i| sum += i.size} 631 | end 632 | 633 | # Update the header information concerning total files and directories in the current directory. 634 | def draw_total_items 635 | header_r.draw_total_items count: items.size, size: items.inject(0) {|sum, i| sum += i.size} 636 | end 637 | 638 | # Swktch on / off marking on the current file or directory. 639 | def toggle_mark 640 | main.toggle_mark current_item 641 | end 642 | 643 | # Get a char as a String from user input. 644 | def get_char 645 | c = Curses.getch 646 | c if (0..255) === c.ord 647 | end 648 | 649 | def clear_command_line 650 | command_line.writeln 0, "" 651 | command_line.clear 652 | command_line.noutrefresh 653 | end 654 | 655 | # Accept user input, and directly execute it as a Ruby method call to the controller. 656 | # 657 | # ==== Parameters 658 | # * +preset_command+ - A command that would be displayed at the command line before user input. 659 | # * +default_argument+ - A default argument for the command. 660 | def process_command_line(preset_command: nil, default_argument: nil) 661 | prompt = preset_command ? ":#{preset_command} " : ':' 662 | command_line.set_prompt prompt 663 | cmd, *args = command_line.get_command(prompt: prompt, default: default_argument).split(' ') 664 | if cmd && !cmd.empty? 665 | ret = self.public_send cmd, *args 666 | clear_command_line 667 | ret 668 | end 669 | rescue Interrupt 670 | clear_command_line 671 | end 672 | 673 | # Accept user input, and directly execute it in an external shell. 674 | def process_shell_command 675 | command_line.set_prompt ':!' 676 | cmd = command_line.get_command(prompt: ':!')[1..-1] 677 | execute_external_command pause: true do 678 | system cmd 679 | end 680 | rescue Interrupt 681 | ensure 682 | command_line.clear 683 | command_line.noutrefresh 684 | end 685 | 686 | # Let the user answer y or n. 687 | # 688 | # ==== Parameters 689 | # * +prompt+ - Prompt message 690 | def ask(prompt = '(y/n)') 691 | command_line.set_prompt prompt 692 | command_line.refresh 693 | while (c = Curses.getch) 694 | next unless [?N, ?Y, ?n, ?y, 3, 27] .include? c # N, Y, n, y, ^c, esc 695 | clear_command_line 696 | break (c == 'y') || (c == 'Y') 697 | end 698 | end 699 | 700 | # Open current file or directory with the editor. 701 | def edit 702 | execute_external_command do 703 | editor = ENV['EDITOR'] || 'vim' 704 | unless in_zip? 705 | system %Q[#{editor} "#{current_item.path}"] 706 | else 707 | begin 708 | tmpdir, tmpfile_name = nil 709 | Zip::File.open(current_zip) do |zip| 710 | tmpdir = Dir.mktmpdir 711 | FileUtils.mkdir_p File.join(tmpdir, File.dirname(current_item.name)) 712 | tmpfile_name = File.join(tmpdir, current_item.name) 713 | File.open(tmpfile_name, 'w') {|f| f.puts zip.file.read(current_item.name)} 714 | system %Q[#{editor} "#{tmpfile_name}"] 715 | zip.add(current_item.name, tmpfile_name) { true } 716 | end 717 | ls 718 | ensure 719 | FileUtils.remove_entry_secure tmpdir if tmpdir 720 | end 721 | end 722 | end 723 | end 724 | 725 | # Open current file or directory with the viewer. 726 | def view 727 | pager = ENV['PAGER'] || 'less' 728 | execute_external_command do 729 | unless in_zip? 730 | system %Q[#{pager} "#{current_item.path}"] 731 | else 732 | begin 733 | tmpdir, tmpfile_name = nil 734 | Zip::File.open(current_zip) do |zip| 735 | tmpdir = Dir.mktmpdir 736 | FileUtils.mkdir_p File.join(tmpdir, File.dirname(current_item.name)) 737 | tmpfile_name = File.join(tmpdir, current_item.name) 738 | File.open(tmpfile_name, 'w') {|f| f.puts zip.file.read(current_item.name)} 739 | end 740 | system %Q[#{pager} "#{tmpfile_name}"] 741 | ensure 742 | FileUtils.remove_entry_secure tmpdir if tmpdir 743 | end 744 | end 745 | end 746 | end 747 | 748 | def move_cursor_by_click(y: nil, x: nil) 749 | if (idx = main.pane_index_at(y: y, x: x)) 750 | row = current_page * max_items + main.maxy * idx + y - main.begy 751 | move_cursor row if (row >= 0) && (row < items.size) 752 | end 753 | end 754 | 755 | private 756 | def execute_external_command(pause: false) 757 | Curses.def_prog_mode 758 | Curses.close_screen 759 | yield 760 | ensure 761 | Curses.reset_prog_mode 762 | Curses.getch if pause 763 | #NOTE needs to draw borders and ls again here since the stdlib Curses.refresh fails to retrieve the previous screen 764 | Rfd::Window.draw_borders 765 | Curses.refresh 766 | ls 767 | end 768 | 769 | def expand_path(path) 770 | File.expand_path path.start_with?('/', '~') ? path : current_dir ? current_dir.join(path) : path 771 | end 772 | 773 | def load_item(path: nil, dir: nil, name: nil, stat: nil) 774 | Item.new dir: dir || File.dirname(path), name: name || File.basename(path), stat: stat, window_width: main.width 775 | end 776 | 777 | def osx? 778 | @_osx ||= RbConfig::CONFIG['host_os'] =~ /darwin/ 779 | end 780 | 781 | def in_zip? 782 | @current_zip 783 | end 784 | 785 | def debug(str) 786 | @debug.debug str 787 | end 788 | end 789 | end 790 | -------------------------------------------------------------------------------- /lib/rfd/commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Rfd 3 | module Commands 4 | # Change permission ("A"ttributes) of selected files and directories. 5 | def a 6 | process_command_line preset_command: 'chmod' 7 | end 8 | 9 | # "c"opy selected files and directories. 10 | def c 11 | process_command_line preset_command: 'cp' 12 | end 13 | 14 | # Soft "d"elete (actually mv to the trash folder on OSX) selected files and directories. 15 | def d 16 | if selected_items.any? 17 | if ask %Q[Are you sure want to trash #{selected_items.one? ? selected_items.first.name : "these #{selected_items.size} files"}? (y/n)] 18 | trash 19 | end 20 | end 21 | end 22 | 23 | # Open current file or directory with the "e"ditor 24 | def e 25 | edit 26 | end 27 | 28 | # "f"ind the first file or directory of which name starts with the given String. 29 | def f 30 | c = get_char and (@last_command = -> { find c }).call 31 | end 32 | 33 | # Move the cursor to the top of the list. 34 | def g 35 | move_cursor 0 36 | end 37 | 38 | # Move the cursor to the left pane. 39 | def h 40 | (y = current_row - maxy) >= 0 and move_cursor y 41 | end 42 | 43 | # Move the cursor down. 44 | def j 45 | move_cursor (current_row + times) % items.size 46 | end 47 | 48 | # Move the cursor up. 49 | def k 50 | move_cursor (current_row - times) % items.size 51 | end 52 | 53 | # Move the cursor to the right pane. 54 | def l 55 | (y = current_row + maxy) < items.size and move_cursor y 56 | end 57 | 58 | # "m"ove selected files and directories. 59 | def m 60 | process_command_line preset_command: 'mv' 61 | end 62 | 63 | # Redo the latest f or F. 64 | def n 65 | @last_command.call if @last_command 66 | end 67 | 68 | # "o"pen selected files and directories with the OS "open" command. 69 | def o 70 | if selected_items.any? 71 | system "open #{selected_items.map {|i| %Q["#{i.path}"]}.join(' ')}" 72 | elsif %w(. ..).include? current_item.name 73 | system %Q[open "#{current_item.path}"] 74 | end 75 | end 76 | 77 | # Paste yanked files / directories into the directory on which the cursor is, or into the current directory. 78 | def p 79 | paste 80 | end 81 | 82 | # "q"uit the app. 83 | def q 84 | raise StopIteration if ask 'Are you sure want to exit? (y/n)' 85 | end 86 | 87 | # "q"uit the app! 88 | def q! 89 | raise StopIteration 90 | end 91 | 92 | # "r"ename selected files and directories. 93 | def r 94 | process_command_line preset_command: 'rename' 95 | end 96 | 97 | # "s"ort displayed files and directories in the given order. 98 | def s 99 | process_command_line preset_command: 'sort' 100 | end 101 | 102 | # Create a new file, or update its timestamp if the file already exists ("t"ouch). 103 | def t 104 | process_command_line preset_command: 'touch' 105 | end 106 | 107 | # "u"narchive .zip and .tar.gz files within selected files and directories into current_directory. 108 | def u 109 | unarchive 110 | end 111 | 112 | # "o"pen selected files and directories with the viewer. 113 | def v 114 | view 115 | end 116 | 117 | # Change o"w"ner of selected files and directories. 118 | def w 119 | process_command_line preset_command: 'chown' 120 | end 121 | 122 | # "y"ank selected file / directory names. 123 | def y 124 | yank 125 | end 126 | 127 | # Archive selected files and directories into a "z"ip file. 128 | def z 129 | process_command_line preset_command: 'zip' 130 | end 131 | 132 | # "C"opy paths of selected files and directory to the "C"lipboard. 133 | def C 134 | clipboard 135 | end 136 | 137 | # Hard "d"elete selected files and directories. 138 | def D 139 | if selected_items.any? 140 | if ask %Q[Are you sure want to delete #{selected_items.one? ? selected_items.first.name : "these #{selected_items.size} files"}? (y/n)] 141 | delete 142 | end 143 | end 144 | end 145 | 146 | # "f"ind the last file or directory of which name starts with the given String. 147 | def F 148 | c = get_char and (@last_command = -> { find_reverse c }).call 149 | end 150 | 151 | # Move the cursor to the top. 152 | def H 153 | move_cursor current_page * max_items 154 | end 155 | 156 | # Move the cursor to the bottom of the list. 157 | def G 158 | move_cursor items.size - 1 159 | end 160 | 161 | # Ma"K"e a directory. 162 | def K 163 | process_command_line preset_command: 'mkdir' 164 | end 165 | 166 | # Move the cursor to the bottom. 167 | def L 168 | move_cursor current_page * max_items + displayed_items.size - 1 169 | end 170 | 171 | # Move the cursor to the "M"iddle. 172 | def M 173 | move_cursor current_page * max_items + displayed_items.size / 2 174 | end 175 | 176 | # "O"pen terminal here. 177 | def O 178 | dir = current_item.directory? ? current_item.path : current_dir.path 179 | system %Q[osascript -e 'tell app "Terminal" 180 | do script "cd #{dir}" 181 | end tell'] if osx? 182 | end 183 | 184 | # "S"ymlink the current file or directory 185 | def S 186 | process_command_line preset_command: 'symlink' 187 | end 188 | 189 | # "T"ouch the current file. This updates current item's timestamp (equivalent to `touch -t`). 190 | def T 191 | process_command_line preset_command: 'touch_t', default_argument: current_item.mtime.tr(': -', '') 192 | end 193 | 194 | # Mark or unmark "a"ll files and directories. 195 | def ctrl_a 196 | mark = marked_items.size != (items.size - 2) # exclude . and .. 197 | items.each {|i| i.toggle_mark unless i.marked? == mark} 198 | draw_items 199 | draw_marked_items 200 | move_cursor current_row 201 | end 202 | 203 | # "b"ack to the previous page. 204 | def ctrl_b 205 | ctrl_p 206 | end 207 | 208 | # "f"orward to the next page. 209 | def ctrl_f 210 | ctrl_n 211 | end 212 | 213 | # Refresh the screen. 214 | def ctrl_l 215 | ls 216 | end 217 | 218 | # Forward to the "n"ext page. 219 | def ctrl_n 220 | move_cursor (current_page + 1) % total_pages * max_items if total_pages > 1 221 | end 222 | 223 | # Back to the "p"revious page. 224 | def ctrl_p 225 | move_cursor (current_page - 1) % total_pages * max_items if total_pages > 1 226 | end 227 | 228 | # Split the main "w"indow into given number of columns. 229 | def ctrl_w 230 | if @times 231 | spawn_panes @times.to_i 232 | ls 233 | end 234 | end 235 | 236 | # Number of times to repeat the next command. 237 | (?0..?9).each do |n| 238 | define_method(n) do 239 | @times ||= '' 240 | @times += n 241 | end 242 | end 243 | 244 | # Return to the previous directory (popd). 245 | def - 246 | popd 247 | end 248 | 249 | # Search files and directories from the current directory. 250 | def / 251 | process_command_line preset_command: 'grep' 252 | end 253 | 254 | # Change current directory (cd). 255 | define_method(:'@') do 256 | process_command_line preset_command: 'cd' 257 | end 258 | 259 | # Execute a shell command in an external shell. 260 | define_method(:!) do 261 | process_shell_command 262 | end 263 | 264 | # Execute a command in the controller context. 265 | define_method(:':') do 266 | process_command_line 267 | end 268 | 269 | # cd into a directory, or view a file. 270 | def enter 271 | if current_item.name == '.' # do nothing 272 | elsif current_item.name == '..' 273 | cd '..' 274 | elsif in_zip? 275 | v 276 | elsif current_item.directory? || current_item.zip? 277 | cd current_item 278 | else 279 | v 280 | end 281 | end 282 | 283 | # Toggle mark, and move down. 284 | def space 285 | toggle_mark 286 | draw_marked_items 287 | j 288 | end 289 | 290 | # cd to the upper hierarchy. 291 | def del 292 | if current_dir.path != '/' 293 | dir_was = times == 1 ? current_dir.name : File.basename(current_dir.join(['..'] * (times - 1))) 294 | cd File.expand_path(current_dir.join(['..'] * times)) 295 | find dir_was 296 | end 297 | end 298 | 299 | # Move cursor position by mouse click. 300 | def click(y: nil, x: nil) 301 | move_cursor_by_click y: y, x: x 302 | end 303 | 304 | # Move cursor position and enter 305 | def double_click(y: nil, x: nil) 306 | if move_cursor_by_click y: y, x: x 307 | enter 308 | end 309 | end 310 | end 311 | end 312 | -------------------------------------------------------------------------------- /lib/rfd/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Rfd 3 | class Item 4 | include Comparable 5 | attr_reader :name, :dir, :stat 6 | attr_accessor :index 7 | 8 | def initialize(path: nil, dir: nil, name: nil, stat: nil, window_width: nil) 9 | @path, @dir, @name, @stat, @window_width, @marked = path, dir || File.dirname(path), name || File.basename(path), stat, window_width, false 10 | @stat = File.lstat self.path unless stat 11 | end 12 | 13 | def path 14 | @path ||= File.join @dir, @name 15 | end 16 | 17 | def basename 18 | @basename ||= File.basename name, extname 19 | end 20 | 21 | def extname 22 | @extname ||= File.extname name 23 | end 24 | 25 | def join(*ary) 26 | File.join path, ary 27 | end 28 | 29 | def full_display_name 30 | n = @name.dup 31 | n << " -> #{target}" if symlink? 32 | n 33 | end 34 | 35 | def display_name 36 | @display_name ||= begin 37 | n = full_display_name 38 | if mb_size(n) <= @window_width - 15 39 | n 40 | elsif symlink? 41 | mb_left n, @window_width - 16 42 | else 43 | "#{mb_left(basename, @window_width - 16 - extname.size)}…#{extname}" 44 | end 45 | end 46 | end 47 | 48 | def color 49 | if symlink? 50 | Curses::COLOR_MAGENTA 51 | elsif hidden? 52 | Curses::COLOR_GREEN 53 | elsif directory? 54 | Curses::COLOR_CYAN 55 | elsif executable? 56 | Curses::COLOR_RED 57 | else 58 | Curses::COLOR_WHITE 59 | end 60 | end 61 | 62 | def size 63 | directory? ? 0 : stat.size 64 | end 65 | 66 | def size_or_dir 67 | directory? ? '' : size.to_s 68 | end 69 | 70 | def atime 71 | stat.atime.strftime('%Y-%m-%d %H:%M:%S') 72 | end 73 | 74 | def ctime 75 | stat.ctime.strftime('%Y-%m-%d %H:%M:%S') 76 | end 77 | 78 | def mtime 79 | stat.mtime.strftime('%Y-%m-%d %H:%M:%S') 80 | end 81 | 82 | def mode 83 | @mode ||= begin 84 | m = stat.mode 85 | ft = directory? ? 'd' : symlink? ? 'l' : '-' 86 | ret = [(m & 0700) / 64, (m & 070) / 8, m & 07].inject(ft) do |str, s| 87 | str += "#{s & 4 == 4 ? 'r' : '-'}#{s & 2 == 2 ? 'w' : '-'}#{s & 1 == 1 ? 'x' : '-'}" 88 | end 89 | if m & 04000 != 0 90 | ret[3] = directory? ? 's' : 'S' 91 | end 92 | if m & 02000 != 0 93 | ret[6] = directory? ? 's' : 'S' 94 | end 95 | if m & 01000 == 512 96 | ret[-1] = directory? ? 't' : 'T' 97 | end 98 | ret 99 | end 100 | end 101 | 102 | def directory? 103 | @directory ||= if symlink? 104 | begin 105 | File.stat(path).directory? 106 | rescue Errno::ENOENT 107 | false 108 | end 109 | else 110 | stat.directory? 111 | end 112 | end 113 | 114 | def symlink? 115 | stat.symlink? 116 | end 117 | 118 | def hidden? 119 | name.start_with?('.') && (name != '.') && (name != '..') 120 | end 121 | 122 | def executable? 123 | stat.executable? 124 | end 125 | 126 | def zip? 127 | @zip_ ||= begin 128 | if directory? 129 | false 130 | else 131 | File.binread(realpath, 4).unpack('V').first == 0x04034b50 132 | end 133 | rescue 134 | false 135 | end 136 | end 137 | 138 | def gz? 139 | @gz_ ||= begin 140 | if directory? 141 | false 142 | else 143 | File.binread(realpath, 2).unpack('n').first == 0x1f8b 144 | end 145 | rescue 146 | false 147 | end 148 | end 149 | 150 | def target 151 | File.readlink path if symlink? 152 | end 153 | 154 | def realpath 155 | @realpath ||= File.realpath path 156 | end 157 | 158 | def toggle_mark 159 | unless %w(. ..).include? name 160 | @marked = !@marked 161 | true 162 | end 163 | end 164 | 165 | def marked? 166 | @marked 167 | end 168 | 169 | def current_mark 170 | marked? ? '*' : ' ' 171 | end 172 | 173 | def mb_left(str, size) 174 | len = 0 175 | index = str.each_char.with_index do |c, i| 176 | break i if len + mb_char_size(c) > size 177 | len += mb_size c 178 | end 179 | str[0, index] 180 | end 181 | 182 | def mb_char_size(c) 183 | c == '…' ? 1 : c.bytesize == 1 ? 1 : 2 184 | end 185 | 186 | def mb_size(str) 187 | str.each_char.inject(0) {|l, c| l += mb_char_size(c)} 188 | end 189 | 190 | def mb_ljust(str, size) 191 | "#{str}#{' ' * [0, size - mb_size(str)].max}" 192 | end 193 | 194 | def to_s 195 | "#{current_mark}#{mb_ljust(display_name, @window_width - 15)}#{size_or_dir.rjust(13)}" 196 | end 197 | 198 | def to_str 199 | path 200 | end 201 | 202 | def <=>(o) 203 | if directory? && !o.directory? 204 | 1 205 | elsif !directory? && o.directory? 206 | -1 207 | else 208 | name <=> o.name 209 | end 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /lib/rfd/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | 5 | module Rfd 6 | @logger = nil 7 | 8 | class << self 9 | def log_to(file) 10 | @logger = Logger.new file 11 | @logger.debug 'hello' 12 | 13 | Rfd::Controller.include Logging 14 | end 15 | 16 | def log(str) 17 | Rfd.logger.debug str if Rfd.logger 18 | end 19 | 20 | attr_reader :logger 21 | end 22 | 23 | module Logging 24 | def self.included(m) 25 | mod = Module.new do 26 | (m.instance_methods - Object.instance_methods).each do |meth| 27 | Rfd.logger.info meth 28 | define_method(meth) {|*args, **kw, &block| Rfd.logger.debug "calling #{meth}(args: #{args.inspect}, kw: #{kw.inspect})"; super(*args, **kw, &block) } 29 | end 30 | end 31 | m.prepend mod 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rfd/reline_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Reline::LineEditor 4 | # override render_finished to suppress printing line break 5 | def render_finished; end 6 | end 7 | -------------------------------------------------------------------------------- /lib/rfd/windows.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'delegate' 3 | 4 | module Rfd 5 | class Window < DelegateClass(Curses::Window) 6 | def self.draw_borders 7 | [[5, Curses.stdscr.maxx, 0, 0], [5, Curses.cols - 30, 0, 0], [Curses.stdscr.maxy - 5, Curses.stdscr.maxx, 4, 0]].each do |height, width, top, left| 8 | w = Curses.stdscr.subwin height, width, top, left 9 | w.bkgdset Curses.color_pair(Curses::COLOR_CYAN) 10 | w.box 0, 0 11 | w.close 12 | end 13 | end 14 | 15 | def initialize(maxy: nil, maxx: nil, begy: nil, begx: nil, window: nil) 16 | super window || Curses.stdscr.subwin(maxy, maxx, begy, begx) 17 | end 18 | 19 | def writeln(row, str) 20 | setpos row, 0 21 | clrtoeol 22 | self << str 23 | refresh 24 | end 25 | end 26 | 27 | class HeaderLeftWindow < Window 28 | def initialize 29 | super maxy: 3, maxx: Curses.cols - 32, begy: 1, begx: 1 30 | end 31 | 32 | def draw_path_and_page_number(path: nil, current: 1, total: nil) 33 | writeln 0, %Q[Page: #{"#{current}/ #{total}".ljust(11)} Path: #{path}] 34 | noutrefresh 35 | end 36 | 37 | def draw_current_file_info(current_file) 38 | draw_current_filename current_file.full_display_name 39 | draw_stat current_file 40 | noutrefresh 41 | end 42 | 43 | private 44 | def draw_current_filename(current_file_name) 45 | writeln 1, "File: #{current_file_name}" 46 | end 47 | 48 | def draw_stat(item) 49 | writeln 2, " #{item.size_or_dir.ljust(13)}#{item.mtime} #{item.mode}" 50 | end 51 | end 52 | 53 | class HeaderRightWindow < Window 54 | def initialize 55 | super maxy: 2, maxx: 29, begy: 2, begx: Curses.cols - 30 56 | end 57 | 58 | def draw_marked_items(count: 0, size: 0) 59 | writeln 0, %Q[#{"#{count}Marked".rjust(11)} #{size.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse.rjust(16)}] 60 | noutrefresh 61 | end 62 | 63 | def draw_total_items(count: 0, size: 0) 64 | writeln 1, %Q[#{"#{count}Files".rjust(10)} #{size.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse.rjust(17)}] 65 | noutrefresh 66 | end 67 | end 68 | 69 | class DebugWindow < Window 70 | def initialize 71 | super maxy: 1, maxx: 29, begy: 1, begx: Curses.cols - 30 72 | end 73 | 74 | def debug(s) 75 | writeln 0, s.to_s 76 | noutrefresh 77 | end 78 | end 79 | 80 | class MainWindow < Window 81 | attr_reader :current_index, :begy 82 | attr_writer :number_of_panes 83 | 84 | def initialize 85 | @begy, @current_index, @number_of_panes = 5, 0, 2 86 | super window: Curses::Pad.new(Curses.lines - 7, Curses.cols - 2) 87 | end 88 | 89 | def newpad(items) 90 | clear 91 | columns = items.size / maxy + 1 92 | newx = width * (((columns - 1) / @number_of_panes + 1) * @number_of_panes) 93 | resize maxy, newx if newx != maxx 94 | 95 | draw_items_to_each_pane items 96 | end 97 | 98 | def display(page) 99 | noutrefresh 0, (Curses.cols - 2) * page, begy, 1, begy + maxy - 1, Curses.cols - 2 100 | end 101 | 102 | def activate_pane(num) 103 | @current_index = num 104 | end 105 | 106 | def pane_index_at(y: nil, x: nil) 107 | (y >= begy) && (begy + maxy > y) && (x / width) 108 | end 109 | 110 | def width 111 | (Curses.cols - 2) / @number_of_panes 112 | end 113 | 114 | def max_items 115 | maxy * @number_of_panes 116 | end 117 | 118 | def draw_item(item, current: false) 119 | setpos item.index % maxy, width * @current_index 120 | attron(Curses.color_pair(item.color) | (current ? Curses::A_UNDERLINE : Curses::A_NORMAL)) do 121 | self << item.to_s 122 | end 123 | end 124 | 125 | def draw_items_to_each_pane(items) 126 | items.each_slice(maxy).each.with_index do |arr, col_index| 127 | arr.each.with_index do |item, i| 128 | setpos i, width * col_index 129 | attron(Curses.color_pair(item.color) | Curses::A_NORMAL) { self << item.to_s } 130 | end 131 | end 132 | end 133 | 134 | def toggle_mark(item) 135 | item.toggle_mark 136 | end 137 | end 138 | 139 | class CommandLineWindow < Window 140 | def initialize 141 | super maxy: 1, maxx: Curses.cols, begy: Curses.lines - 1, begx: 0 142 | end 143 | 144 | def set_prompt(str) 145 | attron(Curses.color_pair(Curses::COLOR_WHITE) | Curses::A_BOLD) do 146 | writeln 0, str 147 | end 148 | end 149 | 150 | def get_command(prompt: nil, default: nil) 151 | startx = prompt ? prompt.size : 1 152 | setpos 0, startx 153 | # Passing the default string to Reline 154 | Reline.pre_input_hook = -> { 155 | Reline.insert_text default || '' 156 | } 157 | s = Reline.readline prompt, true 158 | Rfd.logger.info "reline: #{s}" 159 | "#{prompt[1..-1] if prompt}#{s.strip}" 160 | end 161 | 162 | def show_error(str) 163 | attron(Curses.color_pair(Curses::COLOR_RED) | Curses::A_BOLD) do 164 | writeln 0, str 165 | end 166 | noutrefresh 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /rfd.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "rfd" 8 | spec.version = '0.7.1' 9 | spec.authors = ["Akira Matsuda"] 10 | spec.email = ["ronnie@dio.jp"] 11 | spec.description = 'A Ruby filer that runs on terminal' 12 | spec.summary = 'Ruby on Files & Directories' 13 | spec.homepage = 'https://github.com/amatsuda/rfd' 14 | spec.license = "MIT" 15 | 16 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 17 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | end 19 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 20 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 21 | spec.require_paths = ["lib"] 22 | 23 | spec.add_dependency 'curses', '>= 1.0.0' 24 | spec.add_dependency 'rubyzip', '>= 1.0.0' 25 | spec.add_dependency 'reline' 26 | spec.add_development_dependency 'bundler' 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency 'rspec' 29 | spec.add_development_dependency 'rspec-its' 30 | end 31 | -------------------------------------------------------------------------------- /spec/controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | require 'rfd' 4 | 5 | describe Rfd::Controller do 6 | include CaptureHelper 7 | 8 | around do |example| 9 | @stdout = capture(:stdout) do 10 | FileUtils.cp_r File.join(__dir__, 'testdir'), tmpdir 11 | @rfd = Rfd.start tmpdir 12 | def (@rfd.main).maxy 13 | 3 14 | end 15 | 16 | example.run 17 | 18 | FileUtils.rm_r tmpdir 19 | Dir.chdir __dir__ 20 | end 21 | end 22 | 23 | after :all do 24 | Curses.close_screen 25 | end 26 | 27 | let(:tmpdir) { File.join __dir__, 'tmpdir' } 28 | let!(:controller) { @rfd } 29 | subject { controller } 30 | let(:items) { controller.items } 31 | 32 | describe '#spawn_panes' do 33 | before { controller.spawn_panes 3 } 34 | 35 | subject { controller.main.instance_variable_get :@number_of_panes } 36 | it { should == 3 } 37 | end 38 | 39 | describe '#current_item' do 40 | before do 41 | controller.instance_variable_set :@current_row, 3 42 | end 43 | its(:current_item) { should == items[3] } 44 | end 45 | 46 | describe '#marked_items' do 47 | before do 48 | items[2].toggle_mark 49 | items[3].toggle_mark 50 | end 51 | its(:marked_items) { should == [items[2], items[3]] } 52 | end 53 | 54 | describe '#selected_items' do 55 | context 'When no items were marked' do 56 | context 'When the cursor is on . or ..' do 57 | its(:selected_items) { should be_empty } 58 | end 59 | 60 | context 'When the cursor is not on . nor ..' do 61 | before do 62 | controller.instance_variable_set :@current_row, 5 63 | end 64 | its(:selected_items) { should == [items[5]] } 65 | end 66 | end 67 | context 'When items were marked' do 68 | before do 69 | items[2].toggle_mark 70 | items[4].toggle_mark 71 | end 72 | its(:selected_items) { should == [items[2], items[4]] } 73 | end 74 | end 75 | 76 | describe '#move_cursor' do 77 | context 'When moving to nil' do 78 | before do 79 | controller.move_cursor nil 80 | end 81 | its(:current_row) { should == 0 } 82 | end 83 | context 'When moving to a certain row' do 84 | before do 85 | controller.move_cursor 2 86 | end 87 | its(:current_row) { should == 2 } 88 | 89 | context 'When moving to the second pane' do 90 | before do 91 | controller.move_cursor 5 92 | end 93 | subject { controller.main.instance_variable_get :@current_index } 94 | it { should == 1 } 95 | end 96 | 97 | context 'When moving to the second page' do 98 | before do 99 | controller.move_cursor 7 100 | end 101 | its(:current_page) { should == 1 } 102 | end 103 | end 104 | end 105 | 106 | describe '#cd' do 107 | before do 108 | controller.cd 'dir1' 109 | end 110 | its('current_dir.path') { should == File.join(tmpdir, 'dir1') } 111 | 112 | describe '#popd' do 113 | before do 114 | controller.popd 115 | end 116 | its('current_dir.path') { should == tmpdir } 117 | end 118 | end 119 | 120 | describe '#ls' do 121 | before do 122 | controller.instance_variable_set :@items, [] 123 | controller.ls 124 | end 125 | its(:items) { should_not be_empty } 126 | end 127 | 128 | describe '#sort' do 129 | let(:item) do 130 | Dir.mkdir File.join(tmpdir, '.a') 131 | stat = File.lstat File.join(tmpdir, '.a') 132 | Rfd::Item.new dir: tmpdir, name: '.a', stat: stat, window_width: 100 133 | end 134 | before do 135 | controller.items << item 136 | controller.sort 137 | end 138 | subject { item } 139 | its(:index) { should == 2 } # . .. then next 140 | end 141 | 142 | describe '#chmod' do 143 | let(:item) { controller.items.detect {|i| !i.directory?} } 144 | 145 | context 'With an octet string' do 146 | before do 147 | item.toggle_mark 148 | controller.chmod '666' 149 | end 150 | subject { controller.items.detect {|i| !i.directory?} } 151 | its(:mode) { should == '-rw-rw-rw-' } 152 | end 153 | 154 | context 'With a decimal string' do 155 | before do 156 | item.toggle_mark 157 | controller.chmod '0666' 158 | end 159 | subject { controller.items.detect {|i| !i.directory?} } 160 | its(:mode) { should == '-rw-rw-rw-' } 161 | end 162 | 163 | context 'With a non-numeric string' do 164 | before do 165 | item.toggle_mark 166 | controller.chmod 'a+w' 167 | end 168 | subject { controller.items.detect {|i| !i.directory?} } 169 | its(:mode) { should == '-rw-rw-rw-' } 170 | end 171 | end 172 | 173 | describe '#chown' do 174 | let(:item) { controller.items.detect {|i| !i.directory?} } 175 | subject { item } 176 | 177 | context 'With user name only' do 178 | before do 179 | expect(FileUtils).to receive(:chown).with('alice', nil, Array(item.path)) 180 | item.toggle_mark 181 | end 182 | specify { controller.chown 'alice' } 183 | end 184 | 185 | context 'With group name only' do 186 | before do 187 | expect(FileUtils).to receive(:chown).with(nil, 'admin', Array(item.path)) 188 | item.toggle_mark 189 | end 190 | specify { controller.chown ':admin' } 191 | end 192 | 193 | context 'With both user name and group name' do 194 | before do 195 | expect(FileUtils).to receive(:chown).with('nobody', 'nobody', Array(item.path)) 196 | item.toggle_mark 197 | end 198 | specify { controller.chown 'nobody:nobody' } 199 | end 200 | end 201 | 202 | describe '#find' do 203 | before do 204 | controller.find 'd' 205 | end 206 | its('current_item.name') { should start_with('d') } 207 | end 208 | 209 | describe '#find_reverse' do 210 | before do 211 | controller.find_reverse 'f' 212 | end 213 | its('current_item.name') { should == 'file3' } 214 | end 215 | 216 | describe '#grep' do 217 | before do 218 | controller.grep 'dir' 219 | end 220 | subject { controller.items[2..-1] } 221 | its(:size) { should be > 2 } 222 | it "all items' name should include 'dir'" do 223 | subject.all? {|i| i.name.should include('dir')} 224 | end 225 | end 226 | 227 | describe '#cp' do 228 | before do 229 | controller.find 'file1' 230 | controller.cp 'file4' 231 | end 232 | it 'should be the same file as the copy source file' do 233 | File.read(File.join(tmpdir, 'file1')).should == File.read(File.join(tmpdir, 'file4')) 234 | end 235 | end 236 | 237 | describe '#mv' do 238 | before do 239 | controller.find 'file3' 240 | controller.mv 'dir2' 241 | end 242 | subject { File } 243 | it { should be_exist File.join(tmpdir, 'dir2/file3') } 244 | end 245 | 246 | describe '#rename' do 247 | before do 248 | controller.find '.file2' 249 | controller.toggle_mark 250 | controller.find 'file3' 251 | controller.toggle_mark 252 | controller.rename 'fi/faaai' 253 | end 254 | subject { File } 255 | it { should be_exist File.join(tmpdir, '.faaaile2') } 256 | it { should be_exist File.join(tmpdir, 'faaaile3') } 257 | end 258 | 259 | describe '#trash' do 260 | before do 261 | controller.find 'file3' 262 | controller.toggle_mark 263 | controller.trash 264 | end 265 | it 'should be properly deleted from the current directory' do 266 | controller.items.should be_none {|i| i.name == 'file3'} 267 | end 268 | end 269 | 270 | describe '#delete' do 271 | before do 272 | controller.find 'file3' 273 | controller.toggle_mark 274 | controller.find 'dir2' 275 | controller.toggle_mark 276 | controller.delete 277 | end 278 | it 'should be properly deleted from the current directory' do 279 | controller.items.should be_none {|i| i.name == 'file3'} 280 | controller.items.should be_none {|i| i.name == 'dir2'} 281 | end 282 | end 283 | 284 | describe '#mkdir' do 285 | before do 286 | controller.mkdir 'aho' 287 | end 288 | subject { Dir } 289 | it { should be_exist File.join(tmpdir, 'aho') } 290 | end 291 | 292 | describe '#touch' do 293 | before do 294 | controller.touch 'fuga' 295 | end 296 | subject { File } 297 | it { should be_exist File.join(tmpdir, 'fuga') } 298 | end 299 | 300 | describe '#symlink' do 301 | before do 302 | controller.find 'dir1' 303 | controller.symlink 'aaa' 304 | end 305 | subject { File } 306 | it { should be_symlink File.join(tmpdir, 'aaa') } 307 | end 308 | 309 | describe '#yank' do 310 | before do 311 | controller.find '.file1' 312 | controller.toggle_mark 313 | controller.find 'dir3' 314 | controller.toggle_mark 315 | controller.yank 316 | end 317 | it 'should be yanked' do 318 | controller.instance_variable_get(:@yanked_items).map(&:name).should =~ %w(.file1 dir3) 319 | end 320 | end 321 | 322 | describe '#paste' do 323 | before do 324 | controller.find '.file1' 325 | controller.toggle_mark 326 | controller.find 'dir3' 327 | controller.toggle_mark 328 | controller.yank 329 | end 330 | context 'when the cursor is on a directory' do 331 | before do 332 | controller.find 'dir1' 333 | controller.paste 334 | end 335 | subject { File } 336 | it { should be_exist File.join(tmpdir, 'dir1', '.file1') } 337 | it { should be_exist File.join(tmpdir, 'dir1', 'dir3') } 338 | end 339 | context 'when the cursor is on a file' do 340 | before do 341 | controller.find 'file2' 342 | controller.paste 343 | end 344 | subject { File } 345 | it { should be_exist File.join(tmpdir, '.file1_2') } 346 | it { should be_exist File.join(tmpdir, 'dir3_2') } 347 | end 348 | end 349 | 350 | if RbConfig::CONFIG['host_os'] =~ /darwin/ 351 | describe '#pbcopy' do 352 | before do 353 | controller.find '.file1' 354 | controller.toggle_mark 355 | controller.find 'dir3' 356 | controller.toggle_mark 357 | controller.clipboard 358 | end 359 | it 'copies the selected paths into clipboard' do 360 | `pbpaste`.should == "#{File.join(tmpdir, 'dir3')} #{File.join(tmpdir, '.file1')}" 361 | end 362 | end 363 | end 364 | 365 | describe '#zip' do 366 | before do 367 | controller.find 'dir1' 368 | controller.zip 'archive1' 369 | end 370 | subject { File } 371 | it { should be_exist File.join(tmpdir, 'archive1.zip') } 372 | end 373 | 374 | describe '#unarchive' do 375 | before do 376 | controller.find 'zip1' 377 | controller.toggle_mark 378 | controller.find 'gz1' 379 | controller.toggle_mark 380 | controller.unarchive 381 | end 382 | subject { File } 383 | it { should be_exist File.join(tmpdir, 'zip1/zip_content1') } 384 | it { should be_exist File.join(tmpdir, 'zip1/zip_content_dir1/zip_content1_1') } 385 | it { should be_exist File.join(tmpdir, 'gz1/gz_content1') } 386 | it { should be_exist File.join(tmpdir, 'gz1/gz_content_dir1/gz_content1_1') } 387 | end 388 | 389 | describe '#first_page? and #last_page?' do 390 | context 'When on the first page' do 391 | it { should be_first_page } 392 | it { should_not be_last_page } 393 | end 394 | context 'When on the first page' do 395 | before do 396 | controller.k 397 | end 398 | it { should_not be_first_page } 399 | it { should be_last_page } 400 | end 401 | end 402 | 403 | describe '#total_pages' do 404 | its(:total_pages) { should == 3 } # 15 / (3 * 2) + 1 405 | end 406 | 407 | describe '#switch_page' do 408 | before do 409 | controller.switch_page 2 410 | end 411 | its(:current_page) { should == 2 } 412 | end 413 | 414 | describe '#toggle_mark' do 415 | before do 416 | controller.move_cursor 10 417 | controller.toggle_mark 418 | end 419 | subject { items[10] } 420 | it { should be_marked } 421 | end 422 | 423 | describe 'times' do 424 | subject { controller.times } 425 | context 'before accepting 0-9' do 426 | it { should == 1 } 427 | end 428 | context 'When 0-9 were typed' do 429 | before do 430 | controller.public_send '3' 431 | controller.public_send '7' 432 | end 433 | after do 434 | controller.instance_variable_set :@times, nil 435 | end 436 | it { should == 37 } 437 | end 438 | end 439 | end 440 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | $LOAD_PATH.unshift(File.join(__dir__, '..', 'lib')) 3 | $LOAD_PATH.unshift(__dir__) 4 | 5 | require 'rfd' 6 | 7 | Dir[File.join __dir__, 'support/**/*.rb'].each {|f| require f} 8 | 9 | require 'rspec/its' 10 | -------------------------------------------------------------------------------- /spec/support/capture_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'tempfile' 3 | 4 | # copied from ActiveSupport 4 5 | module CaptureHelper 6 | def capture(stream) 7 | stream = stream.to_s 8 | captured_stream = Tempfile.new(stream) 9 | stream_io = eval("$#{stream}") 10 | origin_stream = stream_io.dup 11 | stream_io.reopen(captured_stream) 12 | 13 | yield 14 | 15 | stream_io.rewind 16 | return captured_stream.read 17 | ensure 18 | captured_stream.close 19 | captured_stream.unlink 20 | stream_io.reopen(origin_stream) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/testdir/.file1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/.file1 -------------------------------------------------------------------------------- /spec/testdir/.file2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/.file2 -------------------------------------------------------------------------------- /spec/testdir/.file3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/.file3 -------------------------------------------------------------------------------- /spec/testdir/.link1: -------------------------------------------------------------------------------- 1 | .file1 -------------------------------------------------------------------------------- /spec/testdir/dir1/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/dir1/.keep -------------------------------------------------------------------------------- /spec/testdir/dir2/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/dir2/.keep -------------------------------------------------------------------------------- /spec/testdir/dir3/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/dir3/.keep -------------------------------------------------------------------------------- /spec/testdir/dirlink1: -------------------------------------------------------------------------------- 1 | dir1 -------------------------------------------------------------------------------- /spec/testdir/file1: -------------------------------------------------------------------------------- 1 | file1 2 | -------------------------------------------------------------------------------- /spec/testdir/file2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/file2 -------------------------------------------------------------------------------- /spec/testdir/file3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/file3 -------------------------------------------------------------------------------- /spec/testdir/gz1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/gz1.tar.gz -------------------------------------------------------------------------------- /spec/testdir/link1: -------------------------------------------------------------------------------- 1 | file1 -------------------------------------------------------------------------------- /spec/testdir/link2: -------------------------------------------------------------------------------- 1 | file2 -------------------------------------------------------------------------------- /spec/testdir/zip1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/rfd/f15802c48329e29a991ce652fdcace3753559f07/spec/testdir/zip1.zip --------------------------------------------------------------------------------