rum 0.0.1-x86-mingw32
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +3 -0
- data/README +30 -0
- data/Rakefile +134 -0
- data/bin/rum-client +124 -0
- data/doc/basic.rb +10 -0
- data/doc/doc.html +602 -0
- data/doc/example.rb +59 -0
- data/doc/reference.rb +415 -0
- data/doc/resources/bg.png +0 -0
- data/doc/resources/bottom.png +0 -0
- data/doc/resources/build.rb +235 -0
- data/doc/resources/doc.haml +167 -0
- data/doc/resources/emacs-auto-completion.png +0 -0
- data/doc/resources/flash.png +0 -0
- data/doc/resources/highlight.css +94 -0
- data/doc/resources/intro.rb +17 -0
- data/doc/resources/left.png +0 -0
- data/doc/resources/logo.png +0 -0
- data/doc/resources/screen.css +420 -0
- data/doc/resources/screenshot.png +0 -0
- data/doc/resources/top.png +0 -0
- data/ext/mac/keyboard_hook/English.lproj/InfoPlist.strings +0 -0
- data/ext/mac/keyboard_hook/Event.h +17 -0
- data/ext/mac/keyboard_hook/Event.m +18 -0
- data/ext/mac/keyboard_hook/EventTap.h +11 -0
- data/ext/mac/keyboard_hook/EventTap.m +77 -0
- data/ext/mac/keyboard_hook/Info.plist +26 -0
- data/ext/mac/keyboard_hook/KeyboardHook.xcodeproj/TemplateIcon.icns +0 -0
- data/ext/mac/keyboard_hook/KeyboardHook.xcodeproj/project.pbxproj +323 -0
- data/ext/mac/keyboard_hook/KeyboardHook_Prefix.pch +7 -0
- data/ext/mac/keyboard_hook/version.plist +16 -0
- data/ext/windows/keyboard_hook/extconf.rb +2 -0
- data/ext/windows/keyboard_hook/keyboard_hook.c +126 -0
- data/ext/windows/system/autohotkey_stuff.c +255 -0
- data/ext/windows/system/autohotkey_stuff.h +2 -0
- data/ext/windows/system/clipboard_watcher.c +58 -0
- data/ext/windows/system/clipboard_watcher.h +2 -0
- data/ext/windows/system/extconf.rb +3 -0
- data/ext/windows/system/input_box.c +239 -0
- data/ext/windows/system/input_box.h +4 -0
- data/ext/windows/system/system.c +273 -0
- data/lib/rum.rb +4 -0
- data/lib/rum/apps.rb +4 -0
- data/lib/rum/barrel.rb +157 -0
- data/lib/rum/barrel/emacs.rb +44 -0
- data/lib/rum/barrel/emacs_client.rb +74 -0
- data/lib/rum/core.rb +125 -0
- data/lib/rum/dsl.rb +109 -0
- data/lib/rum/gui.rb +93 -0
- data/lib/rum/help.rb +128 -0
- data/lib/rum/hotkey_core.rb +479 -0
- data/lib/rum/mac.rb +18 -0
- data/lib/rum/mac/app.rb +4 -0
- data/lib/rum/mac/apps.rb +19 -0
- data/lib/rum/mac/gui.rb +26 -0
- data/lib/rum/mac/gui/growl.rb +54 -0
- data/lib/rum/mac/irb/completion.rb +207 -0
- data/lib/rum/mac/keyboard_hook.rb +73 -0
- data/lib/rum/mac/layouts.rb +146 -0
- data/lib/rum/mac/system.rb +45 -0
- data/lib/rum/remote.rb +48 -0
- data/lib/rum/server.rb +92 -0
- data/lib/rum/windows.rb +23 -0
- data/lib/rum/windows/app.rb +72 -0
- data/lib/rum/windows/apps.rb +25 -0
- data/lib/rum/windows/gui.rb +116 -0
- data/lib/rum/windows/keyboard.rb +80 -0
- data/lib/rum/windows/keyboard_hook.rb +20 -0
- data/lib/rum/windows/keyboard_hook.so +0 -0
- data/lib/rum/windows/layouts.rb +232 -0
- data/lib/rum/windows/system.rb +310 -0
- data/lib/rum/windows/system.so +0 -0
- data/lib/rum/windows/system_foreign_functions.rb +129 -0
- data/rum.gemspec +14 -0
- metadata +156 -0
data/lib/rum.rb
ADDED
data/lib/rum/apps.rb
ADDED
data/lib/rum/barrel.rb
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Rum
|
4
|
+
class Path
|
5
|
+
def self.contents dir
|
6
|
+
paths = []
|
7
|
+
dir = File.join(dir, '')
|
8
|
+
dir_dots = /(?:^|\/)\.\.?$/ # /.. or /.
|
9
|
+
Dir.glob(dir + '**/*', File::FNM_DOTMATCH).each do |path|
|
10
|
+
sub_path = path[(dir.length)..-1].encode(Encoding::UTF_8, \
|
11
|
+
Encoding::ISO_8859_1)
|
12
|
+
paths << sub_path unless sub_path =~ dir_dots
|
13
|
+
end
|
14
|
+
paths
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.select dir
|
18
|
+
path = Gui.choose(nil, contents(dir))
|
19
|
+
(File.join(dir, path)) if path
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.run path
|
23
|
+
start path
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.select_and_run dir
|
27
|
+
path = select(dir)
|
28
|
+
run path if path
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.sanitize path
|
32
|
+
path.gsub(/[\/\\|?*><":]/, '-')
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.normalize path
|
36
|
+
path.gsub('\\', '/')
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
module AppDir
|
41
|
+
class << self
|
42
|
+
attr_accessor :base_dir
|
43
|
+
|
44
|
+
def get(exe)
|
45
|
+
File.join(@base_dir, exe, '')
|
46
|
+
end
|
47
|
+
|
48
|
+
def get_or_create(exe)
|
49
|
+
dir = get(exe)
|
50
|
+
if File.exists? dir
|
51
|
+
dir
|
52
|
+
else
|
53
|
+
prompt = "App-Dir für #{exe.capitalize} anlegen?"
|
54
|
+
if "Erzeugen" == Gui.choose(prompt, ["Erzeugen", "Nicht erzeugen"])
|
55
|
+
Dir.mkdir dir
|
56
|
+
dir
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def current
|
62
|
+
get_or_create(active_window.exe_name)
|
63
|
+
end
|
64
|
+
|
65
|
+
def visit
|
66
|
+
dir = current
|
67
|
+
Dopus.go dir if dir
|
68
|
+
end
|
69
|
+
|
70
|
+
def select
|
71
|
+
dir = current
|
72
|
+
Path.select_and_run dir if dir
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
module Commands
|
78
|
+
class Command
|
79
|
+
def initialize(name, proc)
|
80
|
+
@name = name
|
81
|
+
@proc = proc
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_s
|
85
|
+
@name
|
86
|
+
end
|
87
|
+
|
88
|
+
def run
|
89
|
+
@proc.call
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
class << self
|
94
|
+
attr_accessor :default_tag
|
95
|
+
attr_accessor :commands
|
96
|
+
Commands.commands = {}
|
97
|
+
|
98
|
+
def command(name, *args, &block)
|
99
|
+
args = args.first
|
100
|
+
tag = args[:tag] if args
|
101
|
+
tags = []
|
102
|
+
tags << tag if tag
|
103
|
+
tags << default_tag
|
104
|
+
tags.uniq!
|
105
|
+
|
106
|
+
if args and (hotkey = args[:hotkey])
|
107
|
+
apps = tags.select { |tag| tag.is_a? App }
|
108
|
+
apps.each { |app| hotkey.do(app, &block) }
|
109
|
+
end
|
110
|
+
|
111
|
+
cmd = Command.new(name, block)
|
112
|
+
tags.each do |tag|
|
113
|
+
commands_for_tag = (commands[tag] ||= {})
|
114
|
+
commands_for_tag[name] = cmd
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def [] (tag=nil)
|
119
|
+
if (cmds = @commands[tag])
|
120
|
+
cmds.values
|
121
|
+
else
|
122
|
+
[]
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def select(tag=nil)
|
127
|
+
cmd = Gui.choose(nil, self[tag])
|
128
|
+
cmd.run if cmd
|
129
|
+
end
|
130
|
+
|
131
|
+
def for_active_window
|
132
|
+
cmds = []
|
133
|
+
exe = active_window.exe_name
|
134
|
+
app = App.for_exe(exe)
|
135
|
+
cmds.concat self[app] if app
|
136
|
+
if (dir = AppDir.get(exe))
|
137
|
+
cmds.concat Path.contents(dir)
|
138
|
+
end
|
139
|
+
if (chosen = Gui.choose(nil, cmds))
|
140
|
+
case chosen
|
141
|
+
when String
|
142
|
+
Path.run(dir + chosen)
|
143
|
+
else
|
144
|
+
chosen.run
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def Commands(tag=nil, &block)
|
152
|
+
Commands.default_tag = tag
|
153
|
+
Commands.instance_eval(&block)
|
154
|
+
ensure
|
155
|
+
Commands.default_tag = nil
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'rum/barrel/emacs_client'
|
2
|
+
|
3
|
+
class << Emacs
|
4
|
+
attr_accessor :client, :eval_in_user_buffer
|
5
|
+
|
6
|
+
Emacs.client = EmacsClient.new
|
7
|
+
|
8
|
+
def eval(elisp)
|
9
|
+
# MacRuby hack
|
10
|
+
# @client.eval fails
|
11
|
+
elisp = "(with-current-buffer (window-buffer) #{elisp})" if @eval_in_user_buffer
|
12
|
+
Emacs.client.eval(elisp)
|
13
|
+
end
|
14
|
+
|
15
|
+
def funcall(*args)
|
16
|
+
eval("(#{args.join(' ')})")
|
17
|
+
end
|
18
|
+
|
19
|
+
Quoting = [["\n", '\n'],
|
20
|
+
['"', '\\"']]
|
21
|
+
|
22
|
+
def quote(str)
|
23
|
+
str.gsub!('\\', '\\\\\\\\')
|
24
|
+
Quoting.each { |from, to| str.gsub!(from, to) }
|
25
|
+
'"' << str << '"'
|
26
|
+
end
|
27
|
+
|
28
|
+
def unquote(str)
|
29
|
+
Quoting.reverse.each { |from, to| str.gsub!(to, from) }
|
30
|
+
str.gsub('\\\\', '\\').chomp[1..-2]
|
31
|
+
end
|
32
|
+
|
33
|
+
def find_file(path, line)
|
34
|
+
line = if line.is_a? Fixnum
|
35
|
+
"(goto-line #{line})"
|
36
|
+
end
|
37
|
+
eval("(progn (find-file \"#{path}\")#{line})")
|
38
|
+
end
|
39
|
+
|
40
|
+
def open_file(path, line=nil)
|
41
|
+
Emacs.activate
|
42
|
+
Emacs.find_file(path, line)
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
# Ruby implementation of
|
4
|
+
# emacs_source/lib-src/emacsclient.c
|
5
|
+
|
6
|
+
class EmacsClient
|
7
|
+
def eval(elisp)
|
8
|
+
socket = connect
|
9
|
+
socket.puts "-eval #{quote(elisp)}"
|
10
|
+
result = unquote(socket.read)
|
11
|
+
socket.close
|
12
|
+
format(result)
|
13
|
+
end
|
14
|
+
|
15
|
+
def format(str)
|
16
|
+
str[/.*? (.*)/, 1]
|
17
|
+
end
|
18
|
+
|
19
|
+
def quote str
|
20
|
+
r = str.gsub(/&|^-/, '&\&').gsub("\n", '&n').gsub(' ', '&_')
|
21
|
+
end
|
22
|
+
|
23
|
+
def unquote str
|
24
|
+
str.gsub(/&(.)/) do
|
25
|
+
case $1
|
26
|
+
when 'n'; "\n"
|
27
|
+
when '_'; ' '
|
28
|
+
else $1
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
if RUBY_PLATFORM =~ /mswin|mingw/
|
34
|
+
attr_accessor :ip, :port, :auth_string
|
35
|
+
|
36
|
+
def initialize
|
37
|
+
@server_file = File.join(ENV['HOME'], '.emacs.d', 'server', 'server')
|
38
|
+
read_config
|
39
|
+
end
|
40
|
+
|
41
|
+
def read_config
|
42
|
+
@server_active = File.exists? @server_file
|
43
|
+
return unless @server_active
|
44
|
+
lines = File.readlines(@server_file)
|
45
|
+
@ip, @port = lines.first.match(/(.*?):(\d+)/).captures
|
46
|
+
@auth_string = lines.last
|
47
|
+
end
|
48
|
+
|
49
|
+
def create_socket
|
50
|
+
return unless @server_active
|
51
|
+
begin
|
52
|
+
socket = TCPSocket.open(@ip, @port)
|
53
|
+
socket.write "-auth #@auth_string "
|
54
|
+
socket
|
55
|
+
rescue SystemCallError
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def connect
|
60
|
+
create_socket or (read_config and create_socket) \
|
61
|
+
or raise "Can't connect to Emacs Server."
|
62
|
+
end
|
63
|
+
else # Unix
|
64
|
+
def initialize
|
65
|
+
@socket_path = File.join(ENV['TMPDIR'], "emacs#{Process::Sys.geteuid}", 'server')
|
66
|
+
end
|
67
|
+
|
68
|
+
def connect
|
69
|
+
UNIXSocket.open(@socket_path)
|
70
|
+
rescue SystemCallError => error
|
71
|
+
raise "Can't connect to Emacs Server\n" << error.message
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/rum/core.rb
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
require 'rum/hotkey_core'
|
2
|
+
require 'rum/help'
|
3
|
+
require 'rum/gui'
|
4
|
+
require 'rum/barrel'
|
5
|
+
require 'thread'
|
6
|
+
|
7
|
+
case RUBY_DESCRIPTION
|
8
|
+
when /mswin|mingw/ then require 'rum/windows'
|
9
|
+
when /MacRuby/ then require 'rum/mac'
|
10
|
+
else raise "Platform not yet supported: #{RUBY_PLATFORM}"
|
11
|
+
end
|
12
|
+
|
13
|
+
Encoding.default_external = Encoding::UTF_8
|
14
|
+
|
15
|
+
module Rum
|
16
|
+
autoload :Server, 'rum/server'
|
17
|
+
|
18
|
+
class << self
|
19
|
+
attr_writer :layout
|
20
|
+
attr_reader :hotkey_set, :hotkey_processor, \
|
21
|
+
:work_queue, :worker_thread
|
22
|
+
|
23
|
+
def layout
|
24
|
+
@layout ||= Layouts.default_layout
|
25
|
+
end
|
26
|
+
|
27
|
+
def setup
|
28
|
+
return if setup_completed?
|
29
|
+
@hotkey_set = HotkeySet.new(layout)
|
30
|
+
@hotkey_processor = HotkeyProcessor.new(@hotkey_set)
|
31
|
+
end
|
32
|
+
|
33
|
+
def setup_completed?
|
34
|
+
!!@hotkey_set
|
35
|
+
end
|
36
|
+
|
37
|
+
def start
|
38
|
+
setup
|
39
|
+
Thread.abort_on_exception = true
|
40
|
+
@work_queue = Action.work_queue = Queue.new
|
41
|
+
@worker_thread = start_worker_thread(@work_queue)
|
42
|
+
|
43
|
+
KeyboardHook.start &@hotkey_processor.method(:process_event)
|
44
|
+
end
|
45
|
+
|
46
|
+
def stop
|
47
|
+
KeyboardHook.stop
|
48
|
+
end
|
49
|
+
|
50
|
+
def start_worker_thread(queue)
|
51
|
+
Thread.new do
|
52
|
+
while action = queue.deq
|
53
|
+
begin
|
54
|
+
action.call
|
55
|
+
rescue => exception
|
56
|
+
display_exception exception
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def display_exception(exception)
|
63
|
+
error_message = ["#{exception.class}:", exception,
|
64
|
+
'', *exception.backtrace].join("\n")
|
65
|
+
|
66
|
+
file, line = parse_stack_frame(exception.backtrace.first)
|
67
|
+
if file
|
68
|
+
file = File.expand_path(file)
|
69
|
+
callback = lambda do
|
70
|
+
Gui.message("Click here to jump to the last error:\n\n#{file}:#{line}") do
|
71
|
+
Gui.message error_message, :sticky
|
72
|
+
Gui.open_file file, line
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
Gui.message error_message, :sticky, &callback
|
78
|
+
end
|
79
|
+
|
80
|
+
def parse_stack_frame(frame)
|
81
|
+
if match = frame.match(/^(.+?):(\d+)/)
|
82
|
+
file, line = match.captures
|
83
|
+
# Different file names in IRB stackframes:
|
84
|
+
# Ruby 1.9.1: (eval)
|
85
|
+
# Ruby 1.9.2: <main>
|
86
|
+
# MacRuby 0.10: /working_directory/(eval)
|
87
|
+
if file !~ /\(eval\)$/ and file != '<main>'
|
88
|
+
[file, line.to_i]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def switch_worker_thread
|
94
|
+
return unless Thread.current == @worker_thread
|
95
|
+
old = @work_queue
|
96
|
+
new = @work_queue = Action.work_queue = Queue.new
|
97
|
+
new.enq(old.deq) until old.length == 0
|
98
|
+
@worker_thread = start_worker_thread(new)
|
99
|
+
old.enq nil # Signal the worker thread to stop
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
WorkingDir = Dir.pwd
|
104
|
+
|
105
|
+
def restart
|
106
|
+
Dir.chdir WorkingDir
|
107
|
+
if Thread.current == Rum::Server.thread
|
108
|
+
Thread.new do
|
109
|
+
sleep 0.01 # Allow server to respond. Slightly hacky.
|
110
|
+
restart_platform_specific
|
111
|
+
end
|
112
|
+
true
|
113
|
+
else
|
114
|
+
restart_platform_specific
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def show
|
119
|
+
System.terminal_window.show
|
120
|
+
end
|
121
|
+
|
122
|
+
def hide
|
123
|
+
System.terminal_window.hide
|
124
|
+
end
|
125
|
+
end
|
data/lib/rum/dsl.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
include Rum
|
2
|
+
include System
|
3
|
+
include Keyboard
|
4
|
+
|
5
|
+
module Rum
|
6
|
+
class Action
|
7
|
+
def register
|
8
|
+
Rum.hotkey_set.register(self)
|
9
|
+
end
|
10
|
+
|
11
|
+
def unregister
|
12
|
+
Rum.hotkey_set.unregister(self)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class FileLocation
|
17
|
+
def initialize(file, line)
|
18
|
+
@file = file
|
19
|
+
@line = line
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.from_stack_frame(frame)
|
23
|
+
file, line = Rum.parse_stack_frame(frame)
|
24
|
+
if file
|
25
|
+
file = File.expand_path(file) if File.dirname(file) == '.'
|
26
|
+
new(file, line)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def show
|
31
|
+
Gui.open_file(@file, @line)
|
32
|
+
true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
module GuiMixin
|
38
|
+
# Delegating to Gui instead of directly including Gui avoids dealing
|
39
|
+
# with module hierarchy corner cases that appear when other modules
|
40
|
+
# are later dynamically included into Gui via Gui.use.
|
41
|
+
[:message, :alert, :read, :choose, :open_file, :browse, :goto].each do |method_name|
|
42
|
+
define_method(method_name) do |*args, &block|
|
43
|
+
Gui.send(method_name, *args, &block)
|
44
|
+
end
|
45
|
+
private method_name
|
46
|
+
end
|
47
|
+
end
|
48
|
+
include GuiMixin
|
49
|
+
|
50
|
+
def wait(timeout=5, interval=0.01)
|
51
|
+
start = Time.new
|
52
|
+
loop do
|
53
|
+
return true if yield
|
54
|
+
sleep interval
|
55
|
+
return false if Time.new - start > timeout
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
class String
|
60
|
+
def do(*options, &action)
|
61
|
+
repeated = true
|
62
|
+
options.reject! do |option|
|
63
|
+
case option
|
64
|
+
when :no_repeat
|
65
|
+
repeated = false
|
66
|
+
when String
|
67
|
+
action = lambda { Keyboard.type option }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
if (condition = options.first) and condition.respond_to? :to_matcher
|
71
|
+
matcher = condition.to_matcher
|
72
|
+
condition = lambda { matcher.active? }
|
73
|
+
end
|
74
|
+
location = FileLocation.from_stack_frame(caller.first)
|
75
|
+
Rum.hotkey_set.add_hotkey(self, action, condition, repeated, location)
|
76
|
+
end
|
77
|
+
|
78
|
+
def unregister
|
79
|
+
Rum.hotkey_set.remove_hotkey(self)
|
80
|
+
end
|
81
|
+
|
82
|
+
def translate condition=nil, to
|
83
|
+
if condition and condition.respond_to? :to_matcher
|
84
|
+
matcher = condition.to_matcher
|
85
|
+
condition = lambda { matcher.active? }
|
86
|
+
end
|
87
|
+
Rum.hotkey_set.add_translation(self, to, condition,
|
88
|
+
FileLocation.from_stack_frame(caller.first))
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
class String
|
93
|
+
# Prepare a special version of #do that is only active when the user
|
94
|
+
# calls #do for the first time.
|
95
|
+
# After running Rum.setup it calls and restores the default version of #do.
|
96
|
+
alias :old_do :do
|
97
|
+
def do(*args, &block)
|
98
|
+
Rum.setup
|
99
|
+
String.class_eval do
|
100
|
+
alias :do :old_do
|
101
|
+
undef_method :old_do
|
102
|
+
end
|
103
|
+
action = self.do(*args, &block)
|
104
|
+
# The original location has been invalidated by the
|
105
|
+
# extra call to #do. Replace it.
|
106
|
+
action.location = FileLocation.from_stack_frame(caller.first)
|
107
|
+
action
|
108
|
+
end
|
109
|
+
end
|