discourse-systray 0.1.0 → 0.1.2

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 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