discourse-systray 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 342570ecbf8771500b6be8dd6deba05ceb429b4465746c3fa2fc822dedbff311
4
- data.tar.gz: c6a5dbc3ab88a256c2d9f5a9bda9def61ce26ec46a32280e4a81c144335f1017
3
+ metadata.gz: 5210b401c239734ab826f42710f3d925e65e52d62b9a5f8b3da18d56b874469b
4
+ data.tar.gz: 838fac902e2cad6158ea5c5f52086047f4687b5871e1ce5c4b4989203d6e3eda
5
5
  SHA512:
6
- metadata.gz: 183b3a72d39b1f6525d7a2ce533829349709192f8da6ede5d53ae231441a3064e37e04eaa94050ed3e63327784184c9b004049521c7a79fc6f2db688618aec96
7
- data.tar.gz: 5c803012c9fdcaeea04465e8803e66f3531cec3c3487616256e24ede3b28d4e2a0e3a3e59c583fdb9a1a1618d411de98297b43b77ed7abd2c664e2a4a2e648de
6
+ metadata.gz: a4694037048949cbb48696ff280327b1d1f6f6349685de9e8a55fcfc81f27326e4812e526c0976c7615a0d0076543139c158744cd451841503b32f70d0963597
7
+ data.tar.gz: 7193b7995de0c0ff81ca6224c12d478b916bb3972ec896a4266cfbf7a12dc1881091f9cc28c434d3fc65f190e0902e031dbcca6bb66ff705cd92f7891a53e766
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "discourse_systray/systray"
5
5
 
6
- DiscourseSystemTray.new.run
6
+ DiscourseSystray::Systray.run
@@ -5,503 +5,714 @@ require "timeout"
5
5
  require "fileutils"
6
6
  require "json"
7
7
 
8
- # Parse command line options
9
- OPTIONS = { debug: false, path: nil }
10
-
11
- OptionParser
12
- .new do |opts|
13
- opts.banner = "Usage: systray.rb [options]"
14
- opts.on("--debug", "Enable debug mode") { OPTIONS[:debug] = true }
15
- opts.on("--path PATH", "Set Discourse path") { |p| OPTIONS[:path] = p }
16
- end
17
- .parse!
8
+ module ::DiscourseSystray
9
+ class Systray
10
+ CONFIG_DIR = File.expand_path("~/.config/discourse-systray")
11
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
12
+ OPTIONS = { debug: false, path: nil }
13
+
14
+ def self.load_or_prompt_config
15
+ OptionParser
16
+ .new do |opts|
17
+ opts.banner = "Usage: systray.rb [options]"
18
+ opts.on("--debug", "Enable debug mode") { OPTIONS[:debug] = true }
19
+ opts.on("--path PATH", "Set Discourse path") do |p|
20
+ OPTIONS[:path] = p
21
+ end
22
+ opts.on("--console", "Enable console mode") do
23
+ OPTIONS[:console] = true
24
+ end
25
+ opts.on("--attach", "Attach to existing systray") do
26
+ OPTIONS[:attach] = true
27
+ end
28
+ end
29
+ .parse!
30
+ FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
18
31
 
19
- class DiscourseSystemTray
20
- CONFIG_DIR = File.expand_path("~/.config/discourse-systray")
21
- CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
32
+ if OPTIONS[:path]
33
+ save_config(path: OPTIONS[:path])
34
+ return OPTIONS[:path]
35
+ end
22
36
 
23
- def self.load_or_prompt_config
24
- FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
37
+ if File.exist?(CONFIG_FILE)
38
+ config = JSON.parse(File.read(CONFIG_FILE))
39
+ return config["path"] if config["path"] && Dir.exist?(config["path"])
40
+ end
25
41
 
26
- if OPTIONS[:path]
27
- save_config(path: OPTIONS[:path])
28
- return OPTIONS[:path]
29
- end
42
+ # Show dialog to get path
43
+ dialog =
44
+ Gtk::FileChooserDialog.new(
45
+ title: "Select Discourse Directory",
46
+ parent: nil,
47
+ action: :select_folder,
48
+ buttons: [["Cancel", :cancel], ["Select", :accept]]
49
+ )
50
+
51
+ path = nil
52
+ if dialog.run == :accept
53
+ path = dialog.filename
54
+ save_config(path: path)
55
+ else
56
+ puts "No Discourse path specified. Exiting."
57
+ exit 1
58
+ end
30
59
 
31
- if File.exist?(CONFIG_FILE)
32
- config = JSON.parse(File.read(CONFIG_FILE))
33
- return config["path"] if config["path"] && Dir.exist?(config["path"])
60
+ dialog.destroy
61
+ path
34
62
  end
35
63
 
36
- # Show dialog to get path
37
- dialog =
38
- Gtk::FileChooserDialog.new(
39
- title: "Select Discourse Directory",
40
- parent: nil,
41
- action: :select_folder,
42
- buttons: [["Cancel", :cancel], ["Select", :accept]]
43
- )
64
+ def self.save_config(path:, window_geometry: nil)
65
+ config = { path: path }
66
+ config[:window_geometry] = window_geometry if window_geometry
67
+ File.write(CONFIG_FILE, JSON.generate(config))
68
+ nil # Prevent return value from being printed
69
+ end
44
70
 
45
- path = nil
46
- if dialog.run == :accept
47
- path = dialog.filename
48
- save_config(path: path)
49
- else
50
- puts "No Discourse path specified. Exiting."
51
- exit 1
71
+ def self.load_config
72
+ return {} unless File.exist?(CONFIG_FILE)
73
+ JSON.parse(File.read(CONFIG_FILE))
74
+ rescue JSON::ParserError
75
+ {}
76
+ end
77
+ BUFFER_SIZE = 2000
78
+
79
+ def initialize
80
+ @discourse_path = self.class.load_or_prompt_config unless OPTIONS[:attach]
81
+ @running = false
82
+ @ember_output = []
83
+ @unicorn_output = []
84
+ @processes = {}
85
+ @ember_running = false
86
+ @unicorn_running = false
87
+ @ember_line_count = 0
88
+ @unicorn_line_count = 0
89
+ @status_window = nil
52
90
  end
53
91
 
54
- dialog.destroy
55
- path
56
- end
92
+ def init_systray
93
+ @indicator = Gtk::StatusIcon.new
94
+ @indicator.pixbuf =
95
+ GdkPixbuf::Pixbuf.new(
96
+ file: File.join(File.dirname(__FILE__), "../../assets/discourse.png")
97
+ )
98
+ @indicator.tooltip_text = "Discourse Manager"
99
+
100
+ @indicator.signal_connect("popup-menu") do |tray, button, time|
101
+ menu = Gtk::Menu.new
102
+
103
+ # Create menu items with icons
104
+ start_item = Gtk::ImageMenuItem.new(label: "Start Discourse")
105
+ start_item.image =
106
+ Gtk::Image.new(icon_name: "media-playback-start", size: :menu)
107
+
108
+ stop_item = Gtk::ImageMenuItem.new(label: "Stop Discourse")
109
+ stop_item.image =
110
+ Gtk::Image.new(icon_name: "media-playback-stop", size: :menu)
111
+
112
+ status_item = Gtk::ImageMenuItem.new(label: "Show Status")
113
+ status_item.image =
114
+ Gtk::Image.new(icon_name: "utilities-system-monitor", size: :menu)
115
+
116
+ quit_item = Gtk::ImageMenuItem.new(label: "Quit")
117
+ quit_item.image =
118
+ Gtk::Image.new(icon_name: "application-exit", size: :menu)
119
+
120
+ # Add items in new order
121
+ menu.append(start_item)
122
+ menu.append(stop_item)
123
+ menu.append(Gtk::SeparatorMenuItem.new)
124
+ menu.append(status_item)
125
+ menu.append(Gtk::SeparatorMenuItem.new)
126
+ menu.append(quit_item)
127
+
128
+ start_item.signal_connect("activate") do
129
+ set_icon(:running)
130
+ start_discourse
131
+ @running = true
132
+ end
57
133
 
58
- def self.save_config(path:, window_geometry: nil)
59
- config = { path: path }
60
- config[:window_geometry] = window_geometry if window_geometry
61
- File.write(CONFIG_FILE, JSON.generate(config))
62
- nil # Prevent return value from being printed
63
- end
134
+ stop_item.signal_connect("activate") do
135
+ set_icon(:stopped)
136
+ stop_discourse
137
+ @running = false
138
+ end
64
139
 
65
- def self.load_config
66
- return {} unless File.exist?(CONFIG_FILE)
67
- JSON.parse(File.read(CONFIG_FILE))
68
- rescue JSON::ParserError
69
- {}
70
- end
71
- BUFFER_SIZE = 2000
72
-
73
- def initialize
74
- @discourse_path = self.class.load_or_prompt_config
75
- @indicator = Gtk::StatusIcon.new
76
- @indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: File.join(File.dirname(__FILE__), "../../assets/discourse.png"))
77
- @indicator.tooltip_text = "Discourse Manager"
78
- @running = false
79
- @ember_output = []
80
- @unicorn_output = []
81
- @processes = {}
82
- @ember_running = false
83
- @unicorn_running = false
84
- @status_window = nil
85
-
86
- # Create right-click menu
87
- @indicator.signal_connect("popup-menu") do |tray, button, time|
88
- menu = Gtk::Menu.new
89
-
90
- # Create menu items with icons
91
- start_item = Gtk::ImageMenuItem.new(label: "Start Discourse")
92
- start_item.image =
93
- Gtk::Image.new(icon_name: "media-playback-start", size: :menu)
94
-
95
- stop_item = Gtk::ImageMenuItem.new(label: "Stop Discourse")
96
- stop_item.image =
97
- Gtk::Image.new(icon_name: "media-playback-stop", size: :menu)
98
-
99
- status_item = Gtk::ImageMenuItem.new(label: "Show Status")
100
- status_item.image =
101
- Gtk::Image.new(icon_name: "utilities-system-monitor", size: :menu)
102
-
103
- quit_item = Gtk::ImageMenuItem.new(label: "Quit")
104
- quit_item.image =
105
- Gtk::Image.new(icon_name: "application-exit", size: :menu)
106
-
107
- # Add items in new order
108
- menu.append(start_item)
109
- menu.append(stop_item)
110
- menu.append(Gtk::SeparatorMenuItem.new)
111
- menu.append(status_item)
112
- menu.append(Gtk::SeparatorMenuItem.new)
113
- menu.append(quit_item)
114
-
115
- start_item.signal_connect("activate") do
116
- set_icon(:running)
117
- start_discourse
118
- @running = true
119
- end
140
+ quit_item.signal_connect("activate") do
141
+ cleanup
142
+ Gtk.main_quit
143
+ end
120
144
 
121
- stop_item.signal_connect("activate") do
122
- set_icon(:stopped)
123
- stop_discourse
124
- @running = false
125
- end
145
+ status_item.signal_connect("activate") { show_status_window }
146
+
147
+ menu.show_all
126
148
 
127
- quit_item.signal_connect("activate") do
128
- cleanup
129
- Gtk.main_quit
149
+ # Show/hide items based on running state - AFTER show_all
150
+ start_item.visible = !@running
151
+ stop_item.visible = @running
152
+ menu.popup(nil, nil, button, time)
130
153
  end
154
+ end
131
155
 
132
- status_item.signal_connect("activate") { show_status_window }
156
+ def start_discourse
157
+ @ember_output.clear
158
+ @unicorn_output.clear
133
159
 
134
- menu.show_all
160
+ Dir.chdir(@discourse_path) do
161
+ @processes[:ember] = start_process("bin/ember-cli")
162
+ @ember_running = true
163
+ @processes[:unicorn] = start_process("bin/unicorn")
164
+ @unicorn_running = true
165
+ update_tab_labels if @notebook
166
+ end
167
+ end
135
168
 
136
- # Show/hide items based on running state - AFTER show_all
137
- start_item.visible = !@running
138
- stop_item.visible = @running
139
- menu.popup(nil, nil, button, time)
169
+ def stop_discourse
170
+ cleanup
140
171
  end
141
- end
142
172
 
143
- def start_discourse
144
- @ember_output.clear
145
- @unicorn_output.clear
173
+ def cleanup
174
+ return if @processes.empty?
146
175
 
147
- Dir.chdir(@discourse_path) do
148
- @processes[:ember] = start_process("bin/ember-cli")
149
- @ember_running = true
150
- @processes[:unicorn] = start_process("bin/unicorn")
151
- @unicorn_running = true
152
- update_tab_labels if @notebook
153
- end
154
- end
176
+ # First disable updates to prevent race conditions
177
+ @view_timeouts&.values&.each do |id|
178
+ begin
179
+ GLib::Source.remove(id)
180
+ rescue StandardError => e
181
+ puts "Error removing timeout: #{e}" if OPTIONS[:debug]
182
+ end
183
+ end
184
+ @view_timeouts&.clear
155
185
 
156
- def stop_discourse
157
- cleanup
158
- end
186
+ # Then stop processes
187
+ @processes.each do |name, process|
188
+ begin
189
+ Process.kill("TERM", process[:pid])
190
+ # Wait for process to finish with timeout
191
+ Timeout.timeout(10) { process[:thread].join }
192
+ rescue StandardError => e
193
+ puts "Error stopping #{name}: #{e}" if OPTIONS[:debug]
194
+ end
195
+ end
196
+ @processes.clear
197
+ @ember_running = false
198
+ @unicorn_running = false
159
199
 
160
- def cleanup
161
- return if @processes.empty?
200
+ # Finally clean up UI elements
201
+ update_tab_labels if @notebook && !@notebook.destroyed?
162
202
 
163
- @processes.each do |name, process|
164
- begin
165
- Process.kill("TERM", process[:pid])
166
- # Wait for process to finish with timeout
167
- Timeout.timeout(10) { process[:thread].join }
168
- rescue StandardError => e
169
- puts "Error stopping #{name}: #{e}" if OPTIONS[:debug]
203
+ if @status_window && !@status_window.destroyed?
204
+ @status_window.destroy
205
+ @status_window = nil
170
206
  end
171
207
  end
172
- @processes.clear
173
- @ember_running = false
174
- @unicorn_running = false
175
- update_tab_labels if @notebook
176
-
177
- # Clean up window and timeouts on exit
178
- if @status_window
179
- # Clean up any existing timeouts
180
- if @view_timeouts
181
- @view_timeouts.values.each do |id|
182
- begin
183
- GLib::Source.remove(id)
184
- rescue StandardError
185
- nil
208
+
209
+ def start_process(command, console: false)
210
+ return start_console_process(command) if console
211
+ stdin, stdout, stderr, wait_thr = Open3.popen3(command)
212
+
213
+ # Create a monitor thread that will detect if process dies
214
+ monitor_thread =
215
+ Thread.new do
216
+ wait_thr.value # Wait for process to finish
217
+ is_ember = command.include?("ember-cli")
218
+ @ember_running = false if is_ember
219
+ @unicorn_running = false unless is_ember
220
+ GLib::Idle.add do
221
+ update_tab_labels if @notebook
222
+ false
186
223
  end
187
224
  end
188
- @view_timeouts.clear
189
- end
190
- @status_window.destroy
191
- @status_window = nil
192
- end
193
- end
194
-
195
- def start_process(command)
196
- stdin, stdout, stderr, wait_thr = Open3.popen3(command)
197
225
 
198
- # Create a monitor thread that will detect if process dies
199
- monitor_thread =
226
+ # Monitor stdout - send to both console and UX buffer
200
227
  Thread.new do
201
- wait_thr.value # Wait for process to finish
202
- is_ember = command.include?("ember-cli")
203
- @ember_running = false if is_ember
204
- @unicorn_running = false unless is_ember
205
- GLib::Idle.add do
206
- update_tab_labels if @notebook
207
- false
228
+ while line = stdout.gets
229
+ buffer =
230
+ command.include?("ember-cli") ? @ember_output : @unicorn_output
231
+ publish_to_pipe(line)
232
+ puts "[OUT] #{line}" if OPTIONS[:debug]
233
+ buffer << line
234
+ buffer.shift if buffer.size > BUFFER_SIZE
235
+ # Force GUI update
236
+ GLib::Idle.add do
237
+ update_all_views
238
+ false
239
+ end
208
240
  end
209
241
  end
210
242
 
211
- # Monitor stdout
212
- Thread.new do
213
- while line = stdout.gets
214
- buffer = command.include?("ember-cli") ? @ember_output : @unicorn_output
215
- puts "[OUT] #{line}" if OPTIONS[:debug]
216
- buffer << line
217
- buffer.shift if buffer.size > BUFFER_SIZE
218
- # Force GUI update
219
- GLib::Idle.add do
220
- update_all_views
221
- false
243
+ # Monitor stderr - send to both console and UX buffer
244
+ Thread.new do
245
+ while line = stderr.gets
246
+ buffer =
247
+ command.include?("ember-cli") ? @ember_output : @unicorn_output
248
+ publish_to_pipe("ERROR: #{line}")
249
+ puts "[ERR] #{line}" if OPTIONS[:debug]
250
+ buffer << line
251
+ buffer.shift if buffer.size > BUFFER_SIZE
252
+ # Force GUI update
253
+ GLib::Idle.add do
254
+ update_all_views
255
+ false
256
+ end
222
257
  end
223
258
  end
259
+
260
+ {
261
+ pid: wait_thr.pid,
262
+ stdin: stdin,
263
+ stdout: stdout,
264
+ stderr: stderr,
265
+ thread: wait_thr,
266
+ monitor: monitor_thread
267
+ }
224
268
  end
225
269
 
226
- # Monitor stderr
227
- Thread.new do
228
- while line = stderr.gets
229
- buffer = command.include?("ember-cli") ? @ember_output : @unicorn_output
230
- puts "[ERR] #{line}" if OPTIONS[:debug]
231
- buffer << line
232
- buffer.shift if buffer.size > BUFFER_SIZE
233
- # Force GUI update
234
- GLib::Idle.add do
235
- update_all_views
236
- false
270
+ def show_status_window
271
+ if @status_window&.visible?
272
+ @status_window.present
273
+ # Force window to current workspace in i3
274
+ if @status_window.window
275
+ @status_window.window.raise
276
+ if system("which i3-msg >/dev/null 2>&1")
277
+ # First move to current workspace, then focus
278
+ system(
279
+ "i3-msg '[id=#{@status_window.window.xid}] move workspace current'"
280
+ )
281
+ system("i3-msg '[id=#{@status_window.window.xid}] focus'")
282
+ end
237
283
  end
284
+ return
238
285
  end
239
- end
240
286
 
241
- {
242
- pid: wait_thr.pid,
243
- stdin: stdin,
244
- stdout: stdout,
245
- stderr: stderr,
246
- thread: wait_thr,
247
- monitor: monitor_thread
248
- }
249
- end
287
+ # Clean up any existing window
288
+ if @status_window
289
+ @status_window.destroy
290
+ @status_window = nil
291
+ end
250
292
 
251
- def show_status_window
252
- if @status_window&.visible?
253
- @status_window.present
254
- # Force window to current workspace in i3
255
- if @status_window.window
256
- @status_window.window.raise
257
- if system("which i3-msg >/dev/null 2>&1")
258
- # First move to current workspace, then focus
259
- system(
260
- "i3-msg '[id=#{@status_window.window.xid}] move workspace current'"
261
- )
262
- system("i3-msg '[id=#{@status_window.window.xid}] focus'")
263
- end
293
+ @status_window = Gtk::Window.new("Discourse Status")
294
+ @status_window.set_wmclass("discourse-status", "Discourse Status")
295
+
296
+ # Load saved geometry or use defaults
297
+ config = self.class.load_config
298
+ if config["window_geometry"]
299
+ geo = config["window_geometry"]
300
+ @status_window.move(geo["x"], geo["y"])
301
+ @status_window.resize(geo["width"], geo["height"])
302
+ else
303
+ @status_window.set_default_size(800, 600)
304
+ @status_window.window_position = :center
305
+ end
306
+ @status_window.type_hint = :dialog
307
+ @status_window.set_role("discourse-status-dialog")
308
+
309
+ # Handle window destruction and hide
310
+ @status_window.signal_connect("delete-event") do
311
+ save_window_geometry
312
+ @status_window.hide
313
+ true # Prevent destruction
264
314
  end
265
- return
266
- end
267
315
 
268
- # Clean up any existing window
269
- if @status_window
270
- @status_window.destroy
271
- @status_window = nil
272
- end
316
+ # Save position and size when window is moved or resized
317
+ @status_window.signal_connect("configure-event") do
318
+ save_window_geometry
319
+ false
320
+ end
273
321
 
274
- @status_window = Gtk::Window.new("Discourse Status")
275
- @status_window.set_wmclass("discourse-status", "Discourse Status")
276
-
277
- # Load saved geometry or use defaults
278
- config = self.class.load_config
279
- if config["window_geometry"]
280
- geo = config["window_geometry"]
281
- @status_window.move(geo["x"], geo["y"])
282
- @status_window.resize(geo["width"], geo["height"])
283
- else
284
- @status_window.set_default_size(800, 600)
285
- @status_window.window_position = :center
286
- end
287
- @status_window.type_hint = :dialog
288
- @status_window.set_role("discourse-status-dialog")
289
-
290
- # Handle window destruction and hide
291
- @status_window.signal_connect("delete-event") do
292
- save_window_geometry
293
- @status_window.hide
294
- true # Prevent destruction
295
- end
322
+ @notebook = Gtk::Notebook.new
296
323
 
297
- # Save position and size when window is moved or resized
298
- @status_window.signal_connect("configure-event") do
299
- save_window_geometry
300
- false
301
- end
324
+ @ember_view = create_log_view(@ember_output)
325
+ @ember_label = create_status_label("Ember CLI", @ember_running)
326
+ @notebook.append_page(@ember_view, @ember_label)
302
327
 
303
- @notebook = Gtk::Notebook.new
328
+ @unicorn_view = create_log_view(@unicorn_output)
329
+ @unicorn_label = create_status_label("Unicorn", @unicorn_running)
330
+ @notebook.append_page(@unicorn_view, @unicorn_label)
304
331
 
305
- @ember_view = create_log_view(@ember_output)
306
- @ember_label = create_status_label("Ember CLI", @ember_running)
307
- @notebook.append_page(@ember_view, @ember_label)
332
+ @status_window.add(@notebook)
333
+ @status_window.show_all
334
+ end
308
335
 
309
- @unicorn_view = create_log_view(@unicorn_output)
310
- @unicorn_label = create_status_label("Unicorn", @unicorn_running)
311
- @notebook.append_page(@unicorn_view, @unicorn_label)
336
+ def update_all_views
337
+ return unless @status_window && !@status_window.destroyed?
338
+ return unless @ember_view && @unicorn_view
339
+ return unless @ember_view.child && @unicorn_view.child
340
+ return if @ember_view.destroyed? || @unicorn_view.destroyed?
341
+ return if @ember_view.child.destroyed? || @unicorn_view.child.destroyed?
312
342
 
313
- @status_window.add(@notebook)
314
- @status_window.show_all
315
- end
343
+ begin
344
+ if @ember_view.visible? && @ember_view.child.visible?
345
+ update_log_view(@ember_view.child, @ember_output)
346
+ end
347
+ if @unicorn_view.visible? && @unicorn_view.child.visible?
348
+ update_log_view(@unicorn_view.child, @unicorn_output)
349
+ end
350
+ rescue StandardError => e
351
+ puts "Error updating views: #{e}" if OPTIONS[:debug]
352
+ end
353
+ end
316
354
 
317
- def update_all_views
318
- return unless @ember_view && @unicorn_view
355
+ def create_log_view(buffer)
356
+ scroll = Gtk::ScrolledWindow.new
357
+ text_view = Gtk::TextView.new
358
+ text_view.editable = false
359
+ text_view.wrap_mode = :word
360
+
361
+ # Set white text on black background
362
+ text_view.override_background_color(:normal, Gdk::RGBA.new(0, 0, 0, 1))
363
+ text_view.override_color(:normal, Gdk::RGBA.new(1, 1, 1, 1))
364
+
365
+ # Create text tags for colors
366
+ _tag_table = text_view.buffer.tag_table
367
+ create_ansi_tags(text_view.buffer)
368
+
369
+ # Initial text
370
+ update_log_view(text_view, buffer)
371
+
372
+ # Store timeouts in instance variable for proper cleanup
373
+ @view_timeouts ||= {}
374
+
375
+ # Set up periodic refresh with validity check
376
+ timeout_id =
377
+ GLib::Timeout.add(500) do
378
+ if text_view&.parent.nil? || text_view.destroyed?
379
+ @view_timeouts.delete(text_view.object_id)
380
+ false # Stop the timeout if view is destroyed
381
+ else
382
+ begin
383
+ update_log_view(text_view, buffer)
384
+ rescue StandardError => e
385
+ puts "Error updating log view: #{e}" if OPTIONS[:debug]
386
+ end
387
+ true # Keep the timeout active
388
+ end
389
+ end
319
390
 
320
- update_log_view(@ember_view.child, @ember_output)
321
- update_log_view(@unicorn_view.child, @unicorn_output)
322
- end
391
+ @view_timeouts[text_view.object_id] = timeout_id
323
392
 
324
- def create_log_view(buffer)
325
- scroll = Gtk::ScrolledWindow.new
326
- text_view = Gtk::TextView.new
327
- text_view.editable = false
328
- text_view.wrap_mode = :word
393
+ # Clean up timeout when view is destroyed
394
+ text_view.signal_connect("destroy") do
395
+ if timeout_id = @view_timeouts.delete(text_view.object_id)
396
+ begin
397
+ GLib::Source.remove(timeout_id)
398
+ rescue StandardError
399
+ nil
400
+ end
401
+ end
402
+ end
329
403
 
330
- # Set white text on black background
331
- text_view.override_background_color(:normal, Gdk::RGBA.new(0, 0, 0, 1))
332
- text_view.override_color(:normal, Gdk::RGBA.new(1, 1, 1, 1))
404
+ scroll.add(text_view)
405
+ scroll
406
+ end
333
407
 
334
- # Create text tags for colors
335
- _tag_table = text_view.buffer.tag_table
336
- create_ansi_tags(text_view.buffer)
408
+ def create_ansi_tags(buffer)
409
+ # Basic ANSI colors
410
+ {
411
+ "31" => "#ff6b6b", # Brighter red
412
+ "32" => "#87ff87", # Brighter green
413
+ "33" => "#ffff87", # Brighter yellow
414
+ "34" => "#87d7ff", # Brighter blue
415
+ "35" => "#ff87ff", # Brighter magenta
416
+ "36" => "#87ffff", # Brighter cyan
417
+ "37" => "#ffffff" # White
418
+ }.each do |code, color|
419
+ buffer.create_tag("ansi_#{code}", foreground: color)
420
+ end
421
+
422
+ # Add more tags for bold, etc
423
+ buffer.create_tag("bold", weight: :bold)
424
+ end
337
425
 
338
- # Initial text
339
- update_log_view(text_view, buffer)
426
+ def update_log_view(text_view, buffer)
427
+ return if text_view.nil? || text_view.destroyed?
428
+ return if text_view.buffer.nil? || text_view.buffer.destroyed?
429
+
430
+ # Determine which offset counter to use
431
+ offset_var =
432
+ (
433
+ if buffer.equal?(@ember_output)
434
+ :@ember_line_count
435
+ else
436
+ :@unicorn_line_count
437
+ end
438
+ )
439
+ current_offset = instance_variable_get(offset_var)
440
+
441
+ # Don't call if we've already processed all lines
442
+ return if buffer.size <= current_offset
443
+
444
+ adj = text_view&.parent&.vadjustment
445
+ was_at_bottom = (adj && adj.value >= adj.upper - adj.page_size - 50)
446
+ old_value = adj ? adj.value : 0
447
+
448
+ # Process only the new lines
449
+ new_lines = buffer[current_offset..-1]
450
+ new_lines.each do |line|
451
+ ansi_segments =
452
+ line.scan(/\e\[([0-9;]*)m([^\e]*)|\e\[K([^\e]*)|([^\e]+)/)
453
+
454
+ ansi_segments.each do |codes, text_part, clear_part, plain|
455
+ chunk = text_part || clear_part || plain.to_s
456
+ chunk_start_iter = text_view.buffer.end_iter
457
+ text_view.buffer.insert(chunk_start_iter, chunk)
458
+
459
+ # For each ANSI code, apply tags
460
+ if codes
461
+ codes
462
+ .split(";")
463
+ .each do |code|
464
+ case code
465
+ when "1"
466
+ text_view.buffer.apply_tag(
467
+ "bold",
468
+ chunk_start_iter,
469
+ text_view.buffer.end_iter
470
+ )
471
+ when "31".."37"
472
+ text_view.buffer.apply_tag(
473
+ "ansi_#{code}",
474
+ chunk_start_iter,
475
+ text_view.buffer.end_iter
476
+ )
477
+ end
478
+ end
479
+ end
480
+ end
481
+ end
340
482
 
341
- # Store timeouts in instance variable for proper cleanup
342
- @view_timeouts ||= {}
483
+ # Update our offset counter
484
+ instance_variable_set(offset_var, buffer.size)
343
485
 
344
- # Set up periodic refresh with validity check
345
- timeout_id =
346
- GLib::Timeout.add(1000) do
347
- if text_view&.parent.nil? || !text_view&.parent&.visible?
348
- @view_timeouts.delete(text_view.object_id)
349
- false # Stop the timeout if view is destroyed
486
+ # Restore scroll position
487
+ if adj
488
+ if was_at_bottom
489
+ adj.value = adj.upper - adj.page_size
350
490
  else
351
- begin
352
- update_log_view(text_view, buffer)
353
- rescue StandardError
354
- nil
355
- end
356
- true # Keep the timeout active
491
+ adj.value = old_value
357
492
  end
358
493
  end
494
+ end
359
495
 
360
- @view_timeouts[text_view.object_id] = timeout_id
496
+ def create_status_label(text, running)
497
+ box = Gtk::Box.new(:horizontal, 5)
498
+ label = Gtk::Label.new(text)
499
+ status = Gtk::Label.new
500
+ color =
501
+ (
502
+ if running
503
+ Gdk::RGBA.new(0.2, 0.8, 0.2, 1)
504
+ else
505
+ Gdk::RGBA.new(0.8, 0.2, 0.2, 1)
506
+ end
507
+ )
508
+ status.override_color(:normal, color)
509
+ status.text = running ? "●" : "○"
510
+ box.pack_start(label, expand: false, fill: false, padding: 0)
511
+ box.pack_start(status, expand: false, fill: false, padding: 0)
512
+ box.show_all
513
+ box
514
+ end
515
+
516
+ def update_tab_labels
517
+ return unless @notebook && !@notebook.destroyed?
518
+ return unless @ember_label && @unicorn_label
519
+ return if @ember_label.destroyed? || @unicorn_label.destroyed?
361
520
 
362
- # Clean up timeout when view is destroyed
363
- text_view.signal_connect("destroy") do
364
- if timeout_id = @view_timeouts.delete(text_view.object_id)
521
+ [@ember_label, @unicorn_label].each do |label|
522
+ next unless label.children && label.children.length > 1
523
+ next if label.children[1].destroyed?
524
+
525
+ is_running = label == @ember_label ? @ember_running : @unicorn_running
365
526
  begin
366
- GLib::Source.remove(timeout_id)
367
- rescue StandardError
368
- nil
527
+ label.children[1].text = is_running ? "●" : "○"
528
+ label.children[1].override_color(
529
+ :normal,
530
+ Gdk::RGBA.new(
531
+ is_running ? 0.2 : 0.8,
532
+ is_running ? 0.8 : 0.2,
533
+ 0.2,
534
+ 1
535
+ )
536
+ )
537
+ rescue StandardError => e
538
+ puts "Error updating label: #{e}" if OPTIONS[:debug]
369
539
  end
370
540
  end
371
541
  end
372
542
 
373
- scroll.add(text_view)
374
- scroll
375
- end
543
+ def save_window_geometry
544
+ return unless @status_window&.visible? && @status_window.window
545
+
546
+ x, y = @status_window.position
547
+ width, height = @status_window.size
376
548
 
377
- def create_ansi_tags(buffer)
378
- # Basic ANSI colors
379
- {
380
- "31" => "#ff6b6b", # Brighter red
381
- "32" => "#87ff87", # Brighter green
382
- "33" => "#ffff87", # Brighter yellow
383
- "34" => "#87d7ff", # Brighter blue
384
- "35" => "#ff87ff", # Brighter magenta
385
- "36" => "#87ffff", # Brighter cyan
386
- "37" => "#ffffff" # White
387
- }.each do |code, color|
388
- buffer.create_tag("ansi_#{code}", foreground: color)
549
+ self.class.save_config(
550
+ path: @discourse_path,
551
+ window_geometry: {
552
+ "x" => x,
553
+ "y" => y,
554
+ "width" => width,
555
+ "height" => height
556
+ }
557
+ )
389
558
  end
390
559
 
391
- # Add more tags for bold, etc
392
- buffer.create_tag("bold", weight: :bold)
393
- end
560
+ def set_icon(status)
561
+ icon_file = status == :running ? "discourse_running.png" : "discourse.png"
562
+ icon_path = File.join(File.dirname(__FILE__), "../../assets", icon_file)
563
+ @indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: icon_path)
564
+ end
394
565
 
395
- def update_log_view(text_view, buffer)
396
- return if buffer.empty? || text_view.nil? || !text_view.visible?
397
- return unless text_view.parent&.visible?
398
-
399
- text_view.buffer.text = ""
400
- iter = text_view.buffer.get_iter_at(offset: 0)
401
-
402
- buffer.each do |line|
403
- # Parse ANSI sequences
404
- segments = line.scan(/\e\[([0-9;]*)m([^\e]*)|\e\[K([^\e]*)|([^\e]+)/)
405
-
406
- segments.each do |codes, text, clear_line, plain|
407
- if codes
408
- codes
409
- .split(";")
410
- .each do |code|
411
- case code
412
- when "1"
413
- text_view.buffer.apply_tag("bold", iter, iter)
414
- when "31".."37"
415
- text_view.buffer.apply_tag("ansi_#{code}", iter, iter)
416
- end
417
- end
418
- text_view.buffer.insert(iter, text)
419
- elsif clear_line
420
- text_view.buffer.insert(iter, clear_line)
421
- else
422
- text_view.buffer.insert(iter, plain || "")
566
+ def start_console_process(command)
567
+ stdin, stdout, stderr, wait_thr = Open3.popen3(command)
568
+
569
+ # Pipe stdout to console and add to buffer
570
+ Thread.new do
571
+ while line = stdout.gets
572
+ buffer =
573
+ command.include?("ember-cli") ? @ember_output : @unicorn_output
574
+ print line
575
+ buffer << line
576
+ buffer.shift if buffer.size > BUFFER_SIZE
577
+ end
578
+ end
579
+
580
+ # Pipe stderr to console and add to buffer
581
+ Thread.new do
582
+ while line = stderr.gets
583
+ buffer =
584
+ command.include?("ember-cli") ? @ember_output : @unicorn_output
585
+ STDERR.print line
586
+ buffer << line
587
+ buffer.shift if buffer.size > BUFFER_SIZE
423
588
  end
424
589
  end
590
+
591
+ {
592
+ pid: wait_thr.pid,
593
+ stdin: stdin,
594
+ stdout: stdout,
595
+ stderr: stderr,
596
+ thread: wait_thr
597
+ }
425
598
  end
426
599
 
427
- # Scroll to bottom if near bottom
428
- if text_view&.parent&.vadjustment
429
- adj = text_view.parent.vadjustment
430
- if adj.value >= adj.upper - adj.page_size - 50
431
- adj.value = adj.upper - adj.page_size
600
+ PIPE_PATH = "/tmp/discourse_systray_cmd"
601
+ PID_FILE = "/tmp/discourse_systray.pid"
602
+
603
+ def self.running?
604
+ return false unless File.exist?(PID_FILE)
605
+ pid = File.read(PID_FILE).to_i
606
+ Process.kill(0, pid)
607
+ true
608
+ rescue Errno::ESRCH, Errno::ENOENT
609
+ begin
610
+ File.unlink(PID_FILE)
611
+ rescue StandardError
612
+ nil
432
613
  end
614
+ false
433
615
  end
434
- end
435
616
 
436
- def create_status_label(text, running)
437
- box = Gtk::Box.new(:horizontal, 5)
438
- label = Gtk::Label.new(text)
439
- status = Gtk::Label.new
440
- color =
441
- (
442
- if running
443
- Gdk::RGBA.new(0.2, 0.8, 0.2, 1)
444
- else
445
- Gdk::RGBA.new(0.8, 0.2, 0.2, 1)
617
+ attr_reader :discourse_path
618
+
619
+ def self.run
620
+ new.run
621
+ end
622
+
623
+ def run
624
+ if OPTIONS[:attach]
625
+ require "rb-inotify"
626
+
627
+ notifier = INotify::Notifier.new
628
+
629
+ begin
630
+ pipe = File.open(PIPE_PATH, "r")
631
+
632
+ # Watch for pipe deletion
633
+ notifier.watch(File.dirname(PIPE_PATH), :delete) do |event|
634
+ if event.name == File.basename(PIPE_PATH)
635
+ puts "Pipe was deleted, exiting."
636
+ exit 0
637
+ end
638
+ end
639
+
640
+ # Read from pipe in a separate thread
641
+ reader =
642
+ Thread.new do
643
+ begin
644
+ while true
645
+ if IO.select([pipe], nil, nil, 0.5)
646
+ while line = pipe.gets
647
+ puts line
648
+ STDOUT.flush
649
+ end
650
+ end
651
+
652
+ sleep 0.1
653
+ unless File.exist?(PIPE_PATH)
654
+ puts "Pipe was deleted, exiting."
655
+ exit 0
656
+ end
657
+ end
658
+ rescue EOFError, IOError
659
+ puts "Pipe closed, exiting."
660
+ exit 0
661
+ end
662
+ end
663
+
664
+ # Handle notifications in main thread
665
+ notifier.run
666
+ rescue Errno::ENOENT
667
+ puts "Pipe doesn't exist, exiting."
668
+ exit 1
669
+ ensure
670
+ reader&.kill
671
+ pipe&.close
672
+ notifier&.close
446
673
  end
447
- )
448
- status.override_color(:normal, color)
449
- status.text = running ? "●" : "○"
450
- box.pack_start(label, expand: false, fill: false, padding: 0)
451
- box.pack_start(status, expand: false, fill: false, padding: 0)
452
- box.show_all
453
- box
454
- end
674
+ else
675
+ return if self.class.running?
455
676
 
456
- def update_tab_labels
457
- return unless @notebook
458
- @ember_label.children[1].text = @ember_running ? "●" : "○"
459
- @ember_label.children[1].override_color(
460
- :normal,
461
- Gdk::RGBA.new(
462
- @ember_running ? 0.2 : 0.8,
463
- @ember_running ? 0.8 : 0.2,
464
- 0.2,
465
- 1
466
- )
467
- )
468
-
469
- @unicorn_label.children[1].text = @unicorn_running ? "●" : "○"
470
- @unicorn_label.children[1].override_color(
471
- :normal,
472
- Gdk::RGBA.new(
473
- @unicorn_running ? 0.2 : 0.8,
474
- @unicorn_running ? 0.8 : 0.2,
475
- 0.2,
476
- 1
477
- )
478
- )
479
- end
677
+ system("mkfifo #{PIPE_PATH}") unless File.exist?(PIPE_PATH)
480
678
 
481
- def save_window_geometry
482
- return unless @status_window&.visible? && @status_window.window
679
+ # Create named pipe and write PID file
680
+ system("mkfifo #{PIPE_PATH}") unless File.exist?(PIPE_PATH)
681
+ File.write(PID_FILE, Process.pid.to_s)
483
682
 
484
- x, y = @status_window.position
485
- width, height = @status_window.size
683
+ # Set up cleanup on exit
684
+ at_exit do
685
+ begin
686
+ File.unlink(PIPE_PATH) if File.exist?(PIPE_PATH)
687
+ File.unlink(PID_FILE) if File.exist?(PID_FILE)
688
+ rescue StandardError => e
689
+ puts "Error during cleanup: #{e}" if OPTIONS[:debug]
690
+ end
691
+ end
486
692
 
487
- self.class.save_config(
488
- path: @discourse_path,
489
- window_geometry: {
490
- "x" => x,
491
- "y" => y,
492
- "width" => width,
493
- "height" => height
494
- }
495
- )
496
- end
693
+ # Initialize GTK
694
+ Gtk.init
497
695
 
498
- def set_icon(status)
499
- icon_file = status == :running ? "discourse_running.png" : "discourse.png"
500
- icon_path = File.join(File.dirname(__FILE__), "../../assets", icon_file)
501
- @indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: icon_path)
502
- end
696
+ # Setup systray icon and menu
697
+ init_systray
503
698
 
504
- def run
505
- Gtk.main
699
+ # Start GTK main loop
700
+ Gtk.main
701
+ end
702
+ end
703
+
704
+ def publish_to_pipe(msg)
705
+ return unless File.exist?(PIPE_PATH)
706
+ puts "Publish to pipe: #{msg}" if OPTIONS[:debug]
707
+ begin
708
+ File.open(PIPE_PATH, "w") { |f| f.puts(msg) }
709
+ rescue Errno::EPIPE, IOError => e
710
+ puts "Error writing to pipe: #{e}" if OPTIONS[:debug]
711
+ end
712
+ end
713
+
714
+ def handle_command(cmd)
715
+ puts "Received command: #{cmd}" if OPTIONS[:debug]
716
+ end
506
717
  end
507
718
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: discourse-systray
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Saffron
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-23 00:00:00.000000000 Z
11
+ date: 2025-01-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: gtk3
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rb-inotify
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.10.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.10.1
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: bundler
29
43
  requirement: !ruby/object:Gem::Requirement