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.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.ruby-version +1 -0
- data/API_FOR_EDITOR_INTEGRATION.md +97 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +107 -0
- data/Rakefile +2 -0
- data/bin/working_set +4 -0
- data/lib/api_input_actor.rb +75 -0
- data/lib/basic_actor.rb +8 -0
- data/lib/colors.rb +16 -0
- data/lib/live_updater_actor.rb +29 -0
- data/lib/set_builder_actor.rb +23 -0
- data/lib/set_builder_adapter.rb +2 -0
- data/lib/set_builder_adapter/ag.rb +132 -0
- data/lib/set_viewer_actor.rb +146 -0
- data/lib/user_input_actor.rb +177 -0
- data/lib/view/base.rb +13 -0
- data/lib/view/help.rb +28 -0
- data/lib/view/welcome_user.rb +40 -0
- data/lib/view/working_set.rb +313 -0
- data/lib/working_set.rb +34 -0
- data/lib/working_set_cli.rb +205 -0
- data/lib/working_set_item.rb +40 -0
- data/working_set.gemspec +35 -0
- metadata +226 -0
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'open3'
|
2
|
+
|
3
|
+
module SetBuilderAdapter
|
4
|
+
class Ag
|
5
|
+
|
6
|
+
class ParserError < StandardError; end
|
7
|
+
|
8
|
+
class Parser
|
9
|
+
attr_accessor :input, :parsed_items, :current_item
|
10
|
+
|
11
|
+
def initialize(input)
|
12
|
+
self.input = input
|
13
|
+
self.parsed_items = []
|
14
|
+
end
|
15
|
+
|
16
|
+
def parse
|
17
|
+
input.split("\n").each do |line|
|
18
|
+
parse_line line
|
19
|
+
end
|
20
|
+
record_existing_item
|
21
|
+
parsed_items
|
22
|
+
end
|
23
|
+
|
24
|
+
def learn(key, value)
|
25
|
+
self.current_item[key] = value
|
26
|
+
end
|
27
|
+
|
28
|
+
def add(list_key, value)
|
29
|
+
self.current_item[list_key] ||= []
|
30
|
+
self.current_item[list_key].push value
|
31
|
+
end
|
32
|
+
|
33
|
+
def record_existing_item
|
34
|
+
if current_item
|
35
|
+
parsed_items << current_item
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def new_item_with_file_path(file_path)
|
40
|
+
record_existing_item
|
41
|
+
self.current_item = { }
|
42
|
+
learn :file_path, file_path
|
43
|
+
end
|
44
|
+
|
45
|
+
def parse_line(line)
|
46
|
+
# A new item is started when the current item has no path, or if the
|
47
|
+
# path for the current result line doesn't match the current item's file
|
48
|
+
# path.
|
49
|
+
if current_item == nil or (line != "--" and line != "" and not line.start_with?(current_item[:file_path]))
|
50
|
+
line =~ /^(.*?):\d/
|
51
|
+
new_item_with_file_path $1
|
52
|
+
end
|
53
|
+
|
54
|
+
# The line can be either a pre or post match for the current item
|
55
|
+
if line =~ /^(.*?):(\d+)-(.*)/
|
56
|
+
if current_item[:match_line]
|
57
|
+
add :post_match_lines, $3
|
58
|
+
else
|
59
|
+
add :pre_match_lines, $3
|
60
|
+
end
|
61
|
+
|
62
|
+
# The line can be the actual match itself
|
63
|
+
elsif line =~ /^(.*?):(\d+):(\d+):(.*)/ # match line
|
64
|
+
if current_item[:match_line]
|
65
|
+
new_item_with_file_path current_item[:file_path]
|
66
|
+
end
|
67
|
+
learn :row, $2
|
68
|
+
learn :column, $3
|
69
|
+
learn :match_line, $4
|
70
|
+
|
71
|
+
# Finally, the item can be the inter-file match separator
|
72
|
+
elsif line =~ /--/
|
73
|
+
new_item_with_file_path current_item[:file_path]
|
74
|
+
|
75
|
+
# Weird exception: a blank line will be ignored.
|
76
|
+
elsif line == ""
|
77
|
+
|
78
|
+
# Otherwise big fat fail.
|
79
|
+
else
|
80
|
+
debug_message "Parse failed for:"
|
81
|
+
debug_message line.inspect
|
82
|
+
raise ParserError.new("parse_line failed for: #{line.inspect}")
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
def command_bin
|
89
|
+
"ag"
|
90
|
+
end
|
91
|
+
|
92
|
+
def command_options(options)
|
93
|
+
%W(--search-files -C#{$CONTEXT_LINES} --numbers --column --nogroup --literal) + map_external_options(options)
|
94
|
+
end
|
95
|
+
|
96
|
+
def map_external_options(options)
|
97
|
+
[].tap do |ary|
|
98
|
+
ary << "--word-regexp" if options["whole_word"]
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def command_parts(search_term, options)
|
103
|
+
[ command_bin, *command_options(options), "--", search_term ]
|
104
|
+
end
|
105
|
+
|
106
|
+
def parse_results(results)
|
107
|
+
debug_message "Ag Results:\n#{results}"
|
108
|
+
Parser.new(results).parse
|
109
|
+
rescue ParserError => e
|
110
|
+
STDERR.puts e
|
111
|
+
raise e
|
112
|
+
end
|
113
|
+
|
114
|
+
def build_working_set(search, options)
|
115
|
+
debug_message "search command: #{command_parts(search, options)}"
|
116
|
+
|
117
|
+
stdout, stderr, status = Open3.capture3(*command_parts(search, options))
|
118
|
+
|
119
|
+
# Ag exits 0 when results found
|
120
|
+
# Ag exits 1 when zero results found
|
121
|
+
# ... It also exits 1 when there's a problem with options.
|
122
|
+
if status.exitstatus == 0 || status.exitstatus == 1
|
123
|
+
WorkingSet.new search, options, parse_results(stdout)
|
124
|
+
else
|
125
|
+
raise "ag command failed: #{stdout} #{stderr}"
|
126
|
+
# raise "ag command failed with status #{$?.exitstatus.inspect}: #{stdout}"
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
class SetViewerActor
|
2
|
+
include BasicActor
|
3
|
+
|
4
|
+
def initialize
|
5
|
+
subscribe "tell_selected_item", :tell_selected_item
|
6
|
+
subscribe "copy_selected_item", :copy_selected_item
|
7
|
+
subscribe "tell_selected_item_content", :tell_selected_item_content
|
8
|
+
subscribe "set_build_finished", :refresh_view
|
9
|
+
subscribe "set_build_failed", :show_error
|
10
|
+
subscribe "scroll_changed", :scroll
|
11
|
+
subscribe "context_lines_changed", :update_context_lines
|
12
|
+
subscribe "refresh", :refresh
|
13
|
+
subscribe "show_match_lines_toggled", :toggle_match_lines
|
14
|
+
subscribe "select_next_file", :select_next_file
|
15
|
+
subscribe "select_prev_file", :select_prev_file
|
16
|
+
subscribe "select_next_item", :select_next_item
|
17
|
+
subscribe "select_prev_item", :select_prev_item
|
18
|
+
subscribe "welcome_user", :welcome_user
|
19
|
+
subscribe "display_help", :display_help
|
20
|
+
subscribe "display_working_set", :display_working_set
|
21
|
+
welcome_user
|
22
|
+
end
|
23
|
+
|
24
|
+
def welcome_user(_=nil)
|
25
|
+
debug_message "displaying welcome_user!"
|
26
|
+
View::WelcomeUser.render
|
27
|
+
end
|
28
|
+
|
29
|
+
def display_help(_)
|
30
|
+
debug_message "displaying help!"
|
31
|
+
View::Help.render
|
32
|
+
end
|
33
|
+
|
34
|
+
def display_working_set(_)
|
35
|
+
debug_message "displaying working_set!"
|
36
|
+
@working_set_view&.render
|
37
|
+
end
|
38
|
+
|
39
|
+
def refresh_view(_, working_set)
|
40
|
+
prev_wsv = @working_set_view
|
41
|
+
@working_set_view = View::WorkingSet.new(working_set)
|
42
|
+
if prev_wsv&.working_set&.search == working_set.search
|
43
|
+
@working_set_view.restore_selection_state(prev_wsv)
|
44
|
+
end
|
45
|
+
@working_set_view.render
|
46
|
+
end
|
47
|
+
|
48
|
+
def items_present?
|
49
|
+
(@working_set_view&.working_set&.items&.size || 0) > 0
|
50
|
+
end
|
51
|
+
|
52
|
+
def scroll(_, delta)
|
53
|
+
return unless items_present?
|
54
|
+
@working_set_view.scroll(delta)
|
55
|
+
end
|
56
|
+
|
57
|
+
def update_context_lines(_, delta)
|
58
|
+
$CONTEXT_LINES += delta
|
59
|
+
$CONTEXT_LINES = 0 if $CONTEXT_LINES < 0
|
60
|
+
debug_message "context lines set to #{$CONTEXT_LINES}"
|
61
|
+
refresh
|
62
|
+
end
|
63
|
+
|
64
|
+
def refresh(_=nil)
|
65
|
+
return unless @working_set_view
|
66
|
+
# triggers search again without changing search term
|
67
|
+
ws = @working_set_view.working_set
|
68
|
+
|
69
|
+
publish "search_changed", ws.search, ws.options
|
70
|
+
end
|
71
|
+
|
72
|
+
def toggle_match_lines(_)
|
73
|
+
return unless items_present?
|
74
|
+
@working_set_view.toggle_match_lines
|
75
|
+
end
|
76
|
+
|
77
|
+
def select_next_file(_)
|
78
|
+
return unless items_present?
|
79
|
+
@working_set_view.select_next_file
|
80
|
+
unless @working_set_view.selected_item_in_view?
|
81
|
+
publish "scroll_changed", @working_set_view.selected_item_scroll_delta
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def select_prev_file(_)
|
86
|
+
return unless items_present?
|
87
|
+
@working_set_view.select_prev_file
|
88
|
+
unless @working_set_view.selected_item_in_view?
|
89
|
+
publish "scroll_changed", @working_set_view.selected_item_scroll_delta
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def select_next_item(_)
|
94
|
+
return unless items_present?
|
95
|
+
@working_set_view.select_next_item
|
96
|
+
unless @working_set_view.selected_item_in_view?
|
97
|
+
publish "scroll_changed", @working_set_view.selected_item_scroll_delta
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def select_prev_item(_)
|
102
|
+
return unless items_present?
|
103
|
+
@working_set_view.select_prev_item
|
104
|
+
unless @working_set_view.selected_item_in_view?
|
105
|
+
publish "scroll_changed", @working_set_view.selected_item_scroll_delta
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def tell_selected_item(_)
|
110
|
+
if items_present?
|
111
|
+
item = @working_set_view.selected_item
|
112
|
+
publish "respond_client", "selected_item", {
|
113
|
+
file_path: item.file_path,
|
114
|
+
row: item.row,
|
115
|
+
column: item.column
|
116
|
+
}
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def tell_selected_item_content(_)
|
121
|
+
if items_present?
|
122
|
+
item = @working_set_view.selected_item
|
123
|
+
publish "respond_client", "selected_item_content", {
|
124
|
+
data: item.match_line
|
125
|
+
}
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def copy_selected_item(_, include_context=false)
|
130
|
+
if items_present?
|
131
|
+
item = @working_set_view.selected_item
|
132
|
+
if include_context
|
133
|
+
Clipboard.copy item.full_body
|
134
|
+
else
|
135
|
+
Clipboard.copy item.match_line
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def show_error(_, error)
|
141
|
+
debug_message error
|
142
|
+
# Ncurses.stdscr.mvaddstr 0, 0, "SetViewerActor#show_error: #{error.backtrace}"
|
143
|
+
# Ncurses.stdscr.refresh
|
144
|
+
end
|
145
|
+
|
146
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
class UserInputActor
|
2
|
+
include BasicActor
|
3
|
+
|
4
|
+
finalizer :clean_up
|
5
|
+
|
6
|
+
DEFAULT_SCROLL_STEP = 5
|
7
|
+
|
8
|
+
def self.user_input_mode
|
9
|
+
@user_input_mode
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.prev_user_input_modes
|
13
|
+
@prev_user_input_modes ||= []
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.set_user_input_mode(mode)
|
17
|
+
debug_message "set input mode: #{mode.inspect}"
|
18
|
+
push_mode(mode)
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.push_mode(mode)
|
22
|
+
prev_user_input_modes << @user_input_mode if @user_input_mode
|
23
|
+
@user_input_mode = mode
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.pop_mode
|
27
|
+
@user_input_mode = prev_user_input_modes.pop
|
28
|
+
end
|
29
|
+
|
30
|
+
def user_input_mode
|
31
|
+
self.class.user_input_mode
|
32
|
+
end
|
33
|
+
|
34
|
+
def pop_mode
|
35
|
+
self.class.pop_mode
|
36
|
+
end
|
37
|
+
|
38
|
+
def initialize
|
39
|
+
async.watch_input
|
40
|
+
end
|
41
|
+
|
42
|
+
def watch_input
|
43
|
+
|
44
|
+
# Creating this otherwise unused window so that I can run getch() without
|
45
|
+
# the implicit call to stdscr.refresh that it apparently precipitates.
|
46
|
+
trash_win = Ncurses.newwin(1, 1, 0, 0)
|
47
|
+
trash_win.keypad(true)
|
48
|
+
|
49
|
+
catch(:shutdown) do
|
50
|
+
while(ch = trash_win.getch)
|
51
|
+
debug_message "getch: #{ch}"
|
52
|
+
handle_modal_input(ch)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
debug_message "Caught :shutdown"
|
56
|
+
$supervisor.do_shutdown
|
57
|
+
end
|
58
|
+
|
59
|
+
def handle_modal_input(ch)
|
60
|
+
case user_input_mode
|
61
|
+
when :welcome_user then handle_welcome_user_input(ch)
|
62
|
+
when :help then handle_help_input(ch)
|
63
|
+
when :working_set then handle_working_set_input(ch)
|
64
|
+
else
|
65
|
+
debug_message "Uncrecognized mode: #{user_input_mode.inspect}"
|
66
|
+
throw :shutdown
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def handle_help_input(ch)
|
71
|
+
case ch
|
72
|
+
when ?q.ord
|
73
|
+
mode = pop_mode
|
74
|
+
case mode
|
75
|
+
when :welcome_user then publish "welcome_user"
|
76
|
+
when :working_set then publish "display_working_set"
|
77
|
+
else
|
78
|
+
debug_message "Unrecognized mode from pop: #{mode.inspect}"
|
79
|
+
throw :shutdown
|
80
|
+
end
|
81
|
+
else
|
82
|
+
debug_message "Unhandled user input: #{ch}"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def handle_welcome_user_input(ch)
|
87
|
+
case ch
|
88
|
+
when ?q.ord
|
89
|
+
throw :shutdown
|
90
|
+
when ??.ord
|
91
|
+
publish "display_help"
|
92
|
+
else
|
93
|
+
debug_message "Unhandled user input: #{ch}"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
USER_INPUT_MAPPINGS = {
|
98
|
+
"?" => {
|
99
|
+
desc: "display help",
|
100
|
+
action: -> { publish "display_help" }
|
101
|
+
},
|
102
|
+
"q" => {
|
103
|
+
desc: "quit",
|
104
|
+
action: -> { throw :shutdown }
|
105
|
+
},
|
106
|
+
"j" => {
|
107
|
+
desc: "select next match",
|
108
|
+
action: -> { publish "select_next_item" }
|
109
|
+
},
|
110
|
+
"k" => {
|
111
|
+
desc: "select previous match",
|
112
|
+
action: -> { publish "select_prev_item" }
|
113
|
+
},
|
114
|
+
14 => {
|
115
|
+
key_desc: "ctrl-n",
|
116
|
+
desc: "select first match in next file",
|
117
|
+
action: -> { publish "select_next_file" }
|
118
|
+
},
|
119
|
+
16 => {
|
120
|
+
key_desc: "ctrl-p",
|
121
|
+
desc: "select first match in previous file",
|
122
|
+
action: -> { publish "select_prev_file" }
|
123
|
+
},
|
124
|
+
13 => {
|
125
|
+
key_desc: "enter",
|
126
|
+
desc: "Tell editor to jump to match",
|
127
|
+
action: -> { publish "tell_selected_item" }
|
128
|
+
},
|
129
|
+
Ncurses::KEY_DOWN => {
|
130
|
+
key_desc: "down arrow",
|
131
|
+
desc: "scroll down without changing selection",
|
132
|
+
action: -> { publish "scroll_changed", DEFAULT_SCROLL_STEP }
|
133
|
+
},
|
134
|
+
Ncurses::KEY_UP => {
|
135
|
+
key_desc: "up arrow",
|
136
|
+
desc: "scroll up without changing selection",
|
137
|
+
action: -> { publish "scroll_changed", DEFAULT_SCROLL_STEP * -1 }
|
138
|
+
},
|
139
|
+
"r" => {
|
140
|
+
desc: "refresh search results",
|
141
|
+
action: -> { publish "refresh" }
|
142
|
+
},
|
143
|
+
"[" => {
|
144
|
+
desc: "decrease context lines",
|
145
|
+
action: -> { publish "context_lines_changed", -1 }
|
146
|
+
},
|
147
|
+
"]" => {
|
148
|
+
desc: "increase context lines",
|
149
|
+
action: -> { publish "context_lines_changed", 1 }
|
150
|
+
},
|
151
|
+
"z" => {
|
152
|
+
desc: "toggle showing match lines vs just matched files",
|
153
|
+
action: -> { publish "show_match_lines_toggled" }
|
154
|
+
},
|
155
|
+
"y" => {
|
156
|
+
desc: "copy selected match to system clipboard",
|
157
|
+
action: -> { publish "copy_selected_item" }
|
158
|
+
},
|
159
|
+
"Y" => {
|
160
|
+
desc: "copy selected match + context to system clipboard",
|
161
|
+
action: -> { publish "copy_selected_item", true }
|
162
|
+
},
|
163
|
+
}
|
164
|
+
|
165
|
+
def handle_working_set_input(ch)
|
166
|
+
mapping = USER_INPUT_MAPPINGS[ch] || USER_INPUT_MAPPINGS[ch.chr]
|
167
|
+
if mapping
|
168
|
+
instance_exec(&mapping[:action])
|
169
|
+
end
|
170
|
+
rescue RangeError # ignore when .chr is out of range. Just means it's not input we care about anyways.
|
171
|
+
end
|
172
|
+
|
173
|
+
def clean_up
|
174
|
+
debug_message "done user input"
|
175
|
+
end
|
176
|
+
|
177
|
+
end
|