working_set 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+