working_set 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,13 @@
1
+ class View::Base
2
+
3
+ def stdscr
4
+ Ncurses.stdscr
5
+ end
6
+
7
+ def with_color(window, name)
8
+ window.attron Ncurses.COLOR_PAIR(Colors[name][:number])
9
+ yield if block_given?
10
+ window.attroff Ncurses.COLOR_PAIR(Colors[name][:number])
11
+ end
12
+
13
+ end
@@ -0,0 +1,28 @@
1
+ class View::Help < View::Base
2
+
3
+ def self.render
4
+ new.render
5
+ end
6
+
7
+ def render
8
+ UserInputActor.set_user_input_mode :help
9
+ stdscr.clear
10
+ stdscr.move 0, 0
11
+ stdscr.printw "Key Bindings\n"
12
+ stdscr.printw "------------\n\n"
13
+
14
+ UserInputActor::USER_INPUT_MAPPINGS.each_pair do |k,v|
15
+ with_color(stdscr, :cyan) do
16
+ stdscr.printw " #{v[:key_desc] || k}"
17
+ end
18
+ stdscr.printw " - #{v[:desc]}\n\n"
19
+ end
20
+ with_color(stdscr, :blue) do
21
+ stdscr.printw "Press 'q' to go back."
22
+ end
23
+
24
+ stdscr.refresh
25
+ end
26
+
27
+ end
28
+
@@ -0,0 +1,40 @@
1
+ class View::WelcomeUser < View::Base
2
+
3
+ def self.render
4
+ new.render
5
+ end
6
+
7
+ def render
8
+ UserInputActor.set_user_input_mode :welcome_user
9
+ stdscr.clear
10
+ print_centered \
11
+ [:cyan, "Working Set"],
12
+ "",
13
+ "v#{WorkingSet::VERSION}",
14
+ "by Jim Garvin et al.",
15
+ "",
16
+ [:blue, "Press '?' for help."],
17
+ [:blue, "Press 'q' to quit."]
18
+ stdscr.refresh
19
+ end
20
+
21
+ private
22
+
23
+ def print_centered(*lines)
24
+ start_y = Ncurses.LINES / 2 - lines.size / 2
25
+ lines.each_with_index do |line, i|
26
+ color, msg = if Array === line
27
+ line
28
+ else
29
+ [:white, line]
30
+ end
31
+ x = Ncurses.COLS / 2 - msg.size / 2
32
+ stdscr.move start_y + i, x
33
+ with_color(stdscr, color) do
34
+ stdscr.printw msg
35
+ end
36
+ end
37
+ end
38
+
39
+ end
40
+
@@ -0,0 +1,313 @@
1
+ # +--------------------------------------------------------------------------+
2
+ # | Search or Working Set Name + (indicates dirty/saved state) |
3
+ # +--------------------------------------------------------------------------+
4
+ # | Note if present |
5
+ # +--------------------------------------------------------------------------+
6
+ # | |
7
+ # | app/models/foo.rb |
8
+ # | 10 def foo |
9
+ # | > 11 puts "bar" (highlight match) |
10
+ # | 12 end |
11
+ # | |
12
+ # | app/models/bar.rb |
13
+ # | 10 def bar |
14
+ # | 11 puts "foo" (highlight match) |
15
+ # | 12 end |
16
+ # | |
17
+ # | 10 def bar |
18
+ # | 11 puts "foo" (highlight match) |
19
+ # | 12 end |
20
+ # +--------------------------------------------------------------------------+
21
+ # | 1-10 of 16 items |
22
+ # +--------------------------------------------------------------------------+
23
+ #
24
+ # * Highlight "selected item" with colors.
25
+
26
+ class View::WorkingSet < View::Base
27
+ attr_accessor :working_set, :selected_item_index, :file_index, :scroll_top, :scrollable_height, :show_match_lines
28
+
29
+ TITLE_ROWS = 2
30
+ FOOTER_ROWS = 2
31
+
32
+ def self.render(working_set)
33
+ new(working_set).render
34
+ end
35
+
36
+ def initialize(working_set)
37
+ self.working_set = working_set
38
+ self.file_index = index_files(working_set)
39
+ self.selected_item_index = 0
40
+ self.scroll_top = 0
41
+ self.show_match_lines = true
42
+ end
43
+
44
+ def index_files(working_set)
45
+ index = {}
46
+ prev_file = nil
47
+ sorted_items.each_with_index do |item, idx|
48
+ if prev_file == nil
49
+ # first item in set
50
+ prev_file = index[item.file_path] = { file_path: item.file_path, item_index: idx, prev_file: nil }
51
+ elsif item.file_path == prev_file[:file_path]
52
+ # another match within file, ignore it.
53
+ else
54
+ # next file
55
+ current_file = index[item.file_path] = { file_path: item.file_path, item_index: idx, prev_file: prev_file }
56
+ prev_file[:next_file] = current_file
57
+ prev_file = current_file
58
+ end
59
+ end
60
+ index
61
+ end
62
+
63
+ def selected_item
64
+ sorted_items[selected_item_index]
65
+ end
66
+
67
+ def restore_selection_state(from_working_set_view)
68
+ if idx = sorted_items.find_index(from_working_set_view.selected_item)
69
+ self.selected_item_index = idx
70
+ self.show_match_lines = from_working_set_view.show_match_lines
71
+ render_items_and_footer # have to render to set @scrollable_line_count or the next call won't work
72
+ set_scroll_top(from_working_set_view.scroll_top)
73
+ end
74
+ end
75
+
76
+ def title
77
+ if working_set.name
78
+ "Name: #{working_set.name}"
79
+ else
80
+ "Search: #{working_set.search}"
81
+ end
82
+ end
83
+
84
+ def note
85
+ working_set.note
86
+ end
87
+
88
+ def needs_save?
89
+ !working_set.saved
90
+ end
91
+
92
+ def sorted_items
93
+ @_sorted_items ||= working_set.items.sort_by { |i| [i.file_path, i.row.to_i] }
94
+ end
95
+
96
+ def render
97
+ UserInputActor.set_user_input_mode :working_set
98
+ stdscr.clear
99
+ stdscr.refresh
100
+ render_title.refresh
101
+ render_items.refresh
102
+ render_footer.refresh
103
+ end
104
+
105
+ def toggle_match_lines
106
+ self.show_match_lines = !show_match_lines
107
+ self.scroll_top = 0
108
+ @item_win.clear
109
+ render_items.refresh
110
+ end
111
+
112
+ def render_items_and_footer
113
+ render_items.refresh
114
+ render_footer.refresh
115
+ end
116
+
117
+ def select_next_file
118
+ next_file = file_index[selected_item.file_path][:next_file]
119
+ self.selected_item_index = next_file[:item_index] if next_file
120
+ render_items_and_footer
121
+ end
122
+
123
+ def select_prev_file
124
+ prev_file = file_index[selected_item.file_path][:prev_file]
125
+ self.selected_item_index = prev_file[:item_index] if prev_file
126
+ render_items_and_footer
127
+ end
128
+
129
+ def select_next_item
130
+ self.selected_item_index += 1 unless selected_item_index >= sorted_items.size - 1
131
+ render_items_and_footer
132
+ end
133
+
134
+ def select_prev_item
135
+ self.selected_item_index -= 1 unless selected_item_index <= 0
136
+ render_items_and_footer
137
+ end
138
+
139
+ def scroll(delta)
140
+ return if @scrollable_line_count <= scrollable_height
141
+ set_scroll_top(scroll_top + delta)
142
+ render_items_and_footer
143
+ end
144
+
145
+ def set_scroll_top(value)
146
+ self.scroll_top = if value < 2
147
+ # Reached top
148
+ 0
149
+ elsif (value + scrollable_height) > @scrollable_line_count
150
+ # Reached bottom
151
+ [@scrollable_line_count - scrollable_height, 0].max
152
+ else
153
+ # Normal scroll
154
+ value
155
+ end
156
+ end
157
+
158
+ def scrollable_height
159
+ Ncurses.LINES - TITLE_ROWS - FOOTER_ROWS
160
+ end
161
+
162
+ def scroll_bottom
163
+ scroll_top + scrollable_height
164
+ end
165
+
166
+ def render_title
167
+ # Height, Width, Y, X note: (0 width == full width)
168
+ @title_win ||= Ncurses.newwin(1, 0, 0, 0)
169
+ @title_win.move 0, 0
170
+ print_field @title_win, :left, calc_cols(1), " "
171
+ @title_win.move 0, 0
172
+ with_color @title_win, :blue do
173
+ @title_win.printw title
174
+ end
175
+ if working_set.options["whole_word"]
176
+ with_color @title_win, :red do
177
+ @title_win.printw " [w]"
178
+ end
179
+ end
180
+ # if needs_save?
181
+ # with_color @title_win, :red do
182
+ # @title_win.printw " +"
183
+ # end
184
+ # end
185
+ @title_win
186
+ end
187
+
188
+ def render_footer
189
+ # Height, Width, Y, X note: (0 width == full width)
190
+ @footer_win ||= Ncurses.newwin(1, 0, Ncurses.LINES - 1, 0)
191
+ @footer_win.move 0, 0
192
+ with_color @footer_win, :blue do
193
+ print_field @footer_win, :right, calc_cols(1), "#{selected_item_index + 1} of #{sorted_items.size} (#{file_index.keys.size} files)"
194
+ end
195
+ @footer_win
196
+ end
197
+
198
+ def render_items
199
+
200
+ # Height, Width, Y, X note: (0 width == full width)
201
+ @item_win ||= Ncurses.newwin(scrollable_height, 0, TITLE_ROWS, 0)
202
+
203
+ previous_file_path = nil
204
+ previous_row = 0
205
+ @scrollable_line_number = 0
206
+ @scrollable_line_count = 0
207
+
208
+ sorted_items.each do |item|
209
+
210
+ if !show_match_lines
211
+ if item.file_path != previous_file_path
212
+ color = item.file_path == selected_item.file_path ? :cyan : :green
213
+ puts_scrollable_item 0, color, item.file_path
214
+ end
215
+ else
216
+ # Print file name if it's a new file, a "--" separator if it's the same
217
+ # file but non-consecutive lines, otherwise just nothing.
218
+ if item.file_path == previous_file_path
219
+ if item.row > previous_row + 1
220
+ puts_scrollable_item 0, :white, " --"
221
+ end
222
+ else
223
+ if previous_file_path
224
+ puts_scrollable_item 0, :white, ""
225
+ end
226
+ color = item.file_path == selected_item.file_path ? :cyan : :green
227
+ puts_scrollable_item 0, color, item.file_path
228
+ end
229
+
230
+ # Print pre-match lines.
231
+ item.pre_match_lines.each_with_index do |line, i|
232
+ print_scrollable_item 0, :white, " #{item.row - item.pre_match_lines.size + i}"
233
+ puts_scrollable_item 5, :white, line
234
+ end
235
+
236
+ # Record match line number
237
+ if item == selected_item
238
+ @selected_item_line_number = @scrollable_line_number
239
+ end
240
+
241
+ # Print match line.
242
+ print_scrollable_item 0, :blue, "#{item == selected_item ? ">" : " "}"
243
+ print_scrollable_item 2, :yellow, item.row
244
+ puts_scrollable_item 5, :yellow, item.match_line
245
+
246
+ # Print post-match lines.
247
+ item.post_match_lines.each_with_index do |line, i|
248
+ print_scrollable_item 0, :white, " #{item.row + 1 + i}"
249
+ puts_scrollable_item 5, :white, line
250
+ end
251
+ end
252
+
253
+ previous_file_path = item.file_path
254
+ previous_row = item.row + item.post_match_lines.size
255
+ end
256
+
257
+ @scrollable_line_count = @scrollable_line_number
258
+
259
+ @item_win
260
+ end
261
+
262
+ def puts_scrollable_item(start_col, color_name, content)
263
+ print_scrollable_item(start_col, color_name, content)
264
+ @scrollable_line_number += 1
265
+ end
266
+
267
+ def print_scrollable_item(start_col, color_name, content, *color_content_pairs)
268
+ if scrolled_into_view?(@scrollable_line_number)
269
+ y = scrollable_item_line_number_to_screen_row(@scrollable_line_number)
270
+ x = start_col
271
+ with_color @item_win, color_name do
272
+ @item_win.mvprintw y, x, "%-#{Ncurses.COLS - start_col}s", content
273
+ end
274
+ end
275
+ end
276
+
277
+ def scrollable_item_line_number_to_screen_row(line_number)
278
+ line_number - scroll_top
279
+ end
280
+
281
+ def scrolled_into_view?(line_number, context_lines: 0)
282
+ result = (line_number - context_lines) >= scroll_top && (line_number + context_lines) < scroll_bottom
283
+ debug_message "scrolled_into_view line_number: #{line_number} context_lines: #{context_lines} result: #{result.inspect}"
284
+ result
285
+ end
286
+
287
+ def selected_item_in_view?
288
+ scrolled_into_view? @selected_item_line_number, context_lines: $CONTEXT_LINES
289
+ end
290
+
291
+ def selected_item_scroll_delta
292
+ scroll_padding = 2 + $CONTEXT_LINES
293
+ debug_message "scrolling #{@selected_item_line_number} | #{scroll_top} | #{scroll_bottom}"
294
+ if scroll_top > (@selected_item_line_number - $CONTEXT_LINES)
295
+ # scroll up
296
+ ((scroll_top - @selected_item_line_number) * -1) - scroll_padding
297
+ elsif scroll_bottom < (@selected_item_line_number + $CONTEXT_LINES)
298
+ # scroll down
299
+ @selected_item_line_number - scroll_bottom + scroll_padding
300
+ else
301
+ 0
302
+ end
303
+ end
304
+
305
+ def print_field(window, align, width, content)
306
+ window.printw "%#{align == :left ? "-" : ""}#{width}s", content
307
+ end
308
+
309
+ def calc_cols(percentage)
310
+ (Ncurses.COLS * percentage).to_i
311
+ end
312
+
313
+ end
@@ -0,0 +1,34 @@
1
+ require 'set'
2
+
3
+ class WorkingSet
4
+ attr_accessor :search, :options, :items, :name, :note, :saved
5
+
6
+ VERSION = "1.0.1"
7
+
8
+ def initialize(search = nil, options = nil, items = [])
9
+ self.search = search
10
+ self.options = options
11
+ self.items = []
12
+ items.each { |i| self.add i }
13
+ end
14
+
15
+ def add(item)
16
+ if item.kind_of? WorkingSetItem
17
+ items.push item
18
+ else
19
+ items.push WorkingSetItem.new(item)
20
+ end
21
+ end
22
+
23
+ def inspect
24
+ str = <<EOS
25
+ WorkingSet #{object_id}
26
+ Search: #{search}
27
+ Options: #{options}
28
+ Items:
29
+
30
+ #{items.map(&:inspect).join("\n")}
31
+ EOS
32
+ end
33
+
34
+ end
@@ -0,0 +1,205 @@
1
+ # DEV ONLY
2
+ # Among other things, this adds bundle-installed gems to the load path so the
3
+ # dependencies are require-able.
4
+ require 'bundler/setup' if ENV["WORKING_SET_DEV"] == "true"
5
+
6
+ # External gem dependencies are loaded here.
7
+ require 'celluloid/current'
8
+ require 'celluloid/io'
9
+ require 'ncurses'
10
+ require 'clipboard'
11
+ require 'tty-option'
12
+
13
+ # And zeitwerk takes care of auto-loading the ruby files in this gem.
14
+ require 'zeitwerk'
15
+ loader = Zeitwerk::Loader.for_gem
16
+ loader.setup
17
+
18
+ class WorkingSetCli
19
+
20
+ include TTY::Option
21
+
22
+ usage do
23
+
24
+ no_command # Doesn't seem to work as expected...
25
+ command nil # So I have to do this.
26
+
27
+ header "Working Set: A powerful companion for your favorite text editor."
28
+
29
+ desc "Working Set facilitates very fast searching powered by `ag`, has a convenient ncurses-based interface, and robust editor integration via bi-directional socket API."
30
+ desc "It pairs great with tmux and vim."
31
+ desc "See the README for more:\nhttps://github.com/coderifous/working_set"
32
+
33
+ example <<~EOS
34
+ Run with defaults:
35
+ $ working_set
36
+ EOS
37
+
38
+ example <<~EOS
39
+ Specify socket file:
40
+ $ working_set --socket=/tmp/ws_sock
41
+ EOS
42
+
43
+ example <<~EOS
44
+ Enable watching and auto-refresh for current directory:
45
+ $ working_set --watch
46
+ EOS
47
+
48
+ example <<~EOS
49
+ Enable watching and auto-refresh for specific directory, e.g. the app/ directory of a rails project:
50
+ $ working_set --watch app
51
+ EOS
52
+
53
+ end
54
+
55
+ flag :help do
56
+ short "-h"
57
+ long "--help"
58
+ desc "Print usage"
59
+ end
60
+
61
+ option :watch do
62
+ desc "Auto-refresh working set when file changes detected"
63
+ short "-w"
64
+ long "--watch=[path]"
65
+ end
66
+
67
+ option :socket do
68
+ desc "Set path for IPC socket file (for comms with text editor)"
69
+ short "-s"
70
+ long "--socket=path"
71
+ default ".working_set_socket"
72
+ end
73
+
74
+ option :context do
75
+ desc "How many lines around matches to show"
76
+ short "-c'"
77
+ long "--context=number"
78
+ convert :int
79
+ default 1
80
+ end
81
+
82
+ option :debug do
83
+ hidden
84
+ desc "Set path for debug logging."
85
+ short "-d"
86
+ long "--debug=[path]"
87
+ end
88
+
89
+ def run
90
+ parse
91
+ if params[:help]
92
+ print help
93
+ exit
94
+ else
95
+ init
96
+ $supervisor = AppSupervisor.run!
97
+ sleep 0.5 while $supervisor.alive? # I've need to occupy the main thread otherwise the program exits here.
98
+ end
99
+ end
100
+
101
+ class AppSupervisor < Celluloid::Supervision::Container
102
+ supervise type: SetViewerActor, as: :set_viewer
103
+ supervise type: SetBuilderActor, as: :set_builder
104
+ supervise type: ApiInputActor, as: :api_input
105
+ supervise type: UserInputActor, as: :user_input
106
+
107
+ finalizer :clean_up_ncurses
108
+
109
+ def self.enable_live_watch!
110
+ supervise type: LiveUpdaterActor, as: :live_updater
111
+ end
112
+
113
+ # It seems exiting cleanly requires:
114
+ # - shutdown: to kill the supervised actors
115
+ # - terminate: to kill the supervisor itself
116
+ def do_shutdown
117
+ shutdown
118
+ terminate
119
+ end
120
+
121
+ def clean_up_ncurses
122
+ debug_message "cleaning up Ncurses"
123
+ Ncurses.echo
124
+ Ncurses.nocbreak
125
+ Ncurses.nl
126
+ Ncurses.endwin
127
+ end
128
+
129
+ end
130
+
131
+ private
132
+
133
+ def init
134
+ init_debug
135
+ init_socket_file
136
+ init_live_watch
137
+ init_ncurses
138
+ $CONTEXT_LINES = params[:context]
139
+ end
140
+
141
+ def init_ncurses
142
+ Ncurses.initscr
143
+ Ncurses.cbreak # unbuffered input
144
+ Ncurses.noecho # turn off input echoing
145
+ Ncurses.nonl # turn off newline translation
146
+ Ncurses.stdscr.intrflush(false) # turn off flush-on-interrupt
147
+ Ncurses.stdscr.keypad(true) # turn on keypad mode
148
+ Ncurses.curs_set(0) # hidden cursor
149
+
150
+ Ncurses.start_color
151
+ Ncurses.use_default_colors
152
+
153
+ Colors.each_pair do |k,v|
154
+ Ncurses.init_pair v[:number], v[:pair][0], v[:pair][1]
155
+ end
156
+ end
157
+
158
+ def init_live_watch
159
+ AppSupervisor.enable_live_watch! if params.key?(:watch)
160
+ $LIVE_UPDATE_WATCH_PATH = params.key?(:watch) ? (params[:watch] || ".") : false
161
+ end
162
+
163
+ def init_socket_file
164
+ $SOCKET_PATH = params[:socket]
165
+ check_for_existing_socket_file
166
+ end
167
+
168
+ def check_for_existing_socket_file
169
+ if File.exists?($SOCKET_PATH)
170
+ puts "File #{$SOCKET_PATH.inspect} exists. Overwrite it? (y/N)"
171
+ require "io/console"
172
+ if STDIN.getch =~ /y/i
173
+ File.delete($SOCKET_PATH)
174
+ else
175
+ puts "Ok, exiting program."
176
+ exit
177
+ end
178
+ end
179
+ end
180
+
181
+ def init_debug
182
+ if params.key?(:debug)
183
+ require 'tty-logger'
184
+ path = params[:debug] || "working_set.log"
185
+ log_file = File.open(path, "a")
186
+ log_file.sync = true
187
+ $logger = TTY::Logger.new do |config|
188
+ config.metadata = [:time]
189
+ config.level = :debug
190
+ config.output = log_file
191
+ end
192
+ Celluloid.logger = $logger
193
+ end
194
+ end
195
+
196
+ end
197
+
198
+ class Object
199
+ def debug_message(msg)
200
+ $logger.debug msg if $logger
201
+ end
202
+ end
203
+
204
+ WorkingSetCli.new.run
205
+