├── spec
├── testdir
│ ├── file2
│ ├── file3
│ ├── .file1
│ ├── .file2
│ ├── .file3
│ ├── dir1
│ │ └── .keep
│ ├── dir2
│ │ └── .keep
│ ├── dir3
│ │ └── .keep
│ ├── link1
│ ├── link2
│ ├── .link1
│ ├── dirlink1
│ ├── file1
│ ├── zip1.zip
│ └── gz1.tar.gz
├── spec_helper.rb
├── support
│ └── capture_helper.rb
└── controller_spec.rb
├── Gemfile
├── lib
├── rfd
│ ├── reline_ext.rb
│ ├── logging.rb
│ ├── windows.rb
│ ├── item.rb
│ └── commands.rb
└── rfd.rb
├── .gitignore
├── bin
└── rfd
├── Rakefile
├── MIT-LICENSE
├── rfd.gemspec
└── README.md
/spec/testdir/file2:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/file3:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/.file1:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/.file2:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/.file3:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/dir1/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/dir2/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/dir3/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/testdir/link1:
--------------------------------------------------------------------------------
1 | file1
--------------------------------------------------------------------------------
/spec/testdir/link2:
--------------------------------------------------------------------------------
1 | file2
--------------------------------------------------------------------------------
/spec/testdir/.link1:
--------------------------------------------------------------------------------
1 | .file1
--------------------------------------------------------------------------------
/spec/testdir/dirlink1:
--------------------------------------------------------------------------------
1 | dir1
--------------------------------------------------------------------------------
/spec/testdir/file1:
--------------------------------------------------------------------------------
1 | file1
2 |
--------------------------------------------------------------------------------
/spec/testdir/zip1.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amatsuda/rfd/HEAD/spec/testdir/zip1.zip
--------------------------------------------------------------------------------
/spec/testdir/gz1.tar.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amatsuda/rfd/HEAD/spec/testdir/gz1.tar.gz
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | source 'https://rubygems.org'
3 |
4 | # Specify your gem's dependencies in rfd.gemspec
5 | gemspec
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------