discourse-systray 0.1.1 → 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: 95ff2cc576c4d66442c61d9834a3acec72c3e4580dafbee456207554b16af844
4
- data.tar.gz: efab7206a8bdecf9bccdbd1dc0712954291e559cba789247be93b9b157d2e5d9
3
+ metadata.gz: 5210b401c239734ab826f42710f3d925e65e52d62b9a5f8b3da18d56b874469b
4
+ data.tar.gz: 838fac902e2cad6158ea5c5f52086047f4687b5871e1ce5c4b4989203d6e3eda
5
5
  SHA512:
6
- metadata.gz: 034dc4dbe05f8a2d7f4a4fa8a82ea0e1baae3c200eceb7650a9ef8a37c2de9e8bd950d69d001838ac62c298dc59a9f820fc07b3a1d1baaf5c6e52c844d7e609e
7
- data.tar.gz: 7c2c636e716c62ebc0412d2551c7913f4ecb31a1341ae9d1ae6d5d0eefc1b364868dc162d2217d331e513132be78a6f317ee15e7e3148a214a31906b0d5bfbfa
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,517 +5,714 @@ require "timeout"
5
5
  require "fileutils"
6
6
  require "json"
7
7
 
8
- class DiscourseSystemTray
9
- CONFIG_DIR = File.expand_path("~/.config/discourse-systray")
10
- CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
11
- OPTIONS = { debug: false, path: nil }
12
-
13
- def self.load_or_prompt_config
14
- OptionParser
15
- .new do |opts|
16
- opts.banner = "Usage: systray.rb [options]"
17
- opts.on("--debug", "Enable debug mode") { OPTIONS[:debug] = true }
18
- opts.on("--path PATH", "Set Discourse path") { |p| OPTIONS[:path] = p }
19
- end
20
- .parse!
21
- FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
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)
22
31
 
23
- if OPTIONS[:path]
24
- save_config(path: OPTIONS[:path])
25
- return OPTIONS[:path]
26
- end
32
+ if OPTIONS[:path]
33
+ save_config(path: OPTIONS[:path])
34
+ return OPTIONS[:path]
35
+ end
27
36
 
28
- if File.exist?(CONFIG_FILE)
29
- config = JSON.parse(File.read(CONFIG_FILE))
30
- return config["path"] if config["path"] && Dir.exist?(config["path"])
31
- end
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
32
41
 
33
- # Show dialog to get path
34
- dialog =
35
- Gtk::FileChooserDialog.new(
36
- title: "Select Discourse Directory",
37
- parent: nil,
38
- action: :select_folder,
39
- buttons: [["Cancel", :cancel], ["Select", :accept]]
40
- )
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
41
59
 
42
- path = nil
43
- if dialog.run == :accept
44
- path = dialog.filename
45
- save_config(path: path)
46
- else
47
- puts "No Discourse path specified. Exiting."
48
- exit 1
60
+ dialog.destroy
61
+ path
49
62
  end
50
63
 
51
- dialog.destroy
52
- path
53
- end
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
54
70
 
55
- def self.save_config(path:, window_geometry: nil)
56
- config = { path: path }
57
- config[:window_geometry] = window_geometry if window_geometry
58
- File.write(CONFIG_FILE, JSON.generate(config))
59
- nil # Prevent return value from being printed
60
- end
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
90
+ end
61
91
 
62
- def self.load_config
63
- return {} unless File.exist?(CONFIG_FILE)
64
- JSON.parse(File.read(CONFIG_FILE))
65
- rescue JSON::ParserError
66
- {}
67
- end
68
- BUFFER_SIZE = 2000
69
-
70
- def initialize
71
- @discourse_path = self.class.load_or_prompt_config
72
- @indicator = Gtk::StatusIcon.new
73
- @indicator.pixbuf =
74
- GdkPixbuf::Pixbuf.new(
75
- file: File.join(File.dirname(__FILE__), "../../assets/discourse.png")
76
- )
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
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
120
133
 
121
- stop_item.signal_connect("activate") do
122
- set_icon(:stopped)
123
- stop_discourse
124
- @running = false
125
- end
134
+ stop_item.signal_connect("activate") do
135
+ set_icon(:stopped)
136
+ stop_discourse
137
+ @running = false
138
+ end
126
139
 
127
- quit_item.signal_connect("activate") do
128
- cleanup
129
- Gtk.main_quit
130
- end
140
+ quit_item.signal_connect("activate") do
141
+ cleanup
142
+ Gtk.main_quit
143
+ end
131
144
 
132
- status_item.signal_connect("activate") { show_status_window }
145
+ status_item.signal_connect("activate") { show_status_window }
133
146
 
134
- menu.show_all
147
+ menu.show_all
135
148
 
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)
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)
153
+ end
140
154
  end
141
- end
142
155
 
143
- def start_discourse
144
- @ember_output.clear
145
- @unicorn_output.clear
156
+ def start_discourse
157
+ @ember_output.clear
158
+ @unicorn_output.clear
146
159
 
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
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
153
167
  end
154
- end
155
168
 
156
- def stop_discourse
157
- cleanup
158
- end
169
+ def stop_discourse
170
+ cleanup
171
+ end
159
172
 
160
- def cleanup
161
- return if @processes.empty?
173
+ def cleanup
174
+ return if @processes.empty?
162
175
 
163
- # First disable updates to prevent race conditions
164
- @view_timeouts&.values&.each do |id|
165
- begin
166
- GLib::Source.remove(id)
167
- rescue StandardError => e
168
- puts "Error removing timeout: #{e}" if OPTIONS[:debug]
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
169
183
  end
170
- end
171
- @view_timeouts&.clear
184
+ @view_timeouts&.clear
172
185
 
173
- # Then stop processes
174
- @processes.each do |name, process|
175
- begin
176
- Process.kill("TERM", process[:pid])
177
- # Wait for process to finish with timeout
178
- Timeout.timeout(10) { process[:thread].join }
179
- rescue StandardError => e
180
- puts "Error stopping #{name}: #{e}" if OPTIONS[:debug]
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
181
195
  end
182
- end
183
- @processes.clear
184
- @ember_running = false
185
- @unicorn_running = false
196
+ @processes.clear
197
+ @ember_running = false
198
+ @unicorn_running = false
186
199
 
187
- # Finally clean up UI elements
188
- update_tab_labels if @notebook && !@notebook.destroyed?
200
+ # Finally clean up UI elements
201
+ update_tab_labels if @notebook && !@notebook.destroyed?
189
202
 
190
- if @status_window && !@status_window.destroyed?
191
- @status_window.destroy
192
- @status_window = nil
203
+ if @status_window && !@status_window.destroyed?
204
+ @status_window.destroy
205
+ @status_window = nil
206
+ end
193
207
  end
194
- end
195
208
 
196
- def start_process(command)
197
- stdin, stdout, stderr, wait_thr = Open3.popen3(command)
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
223
+ end
224
+ end
198
225
 
199
- # Create a monitor thread that will detect if process dies
200
- monitor_thread =
226
+ # Monitor stdout - send to both console and UX buffer
201
227
  Thread.new do
202
- wait_thr.value # Wait for process to finish
203
- is_ember = command.include?("ember-cli")
204
- @ember_running = false if is_ember
205
- @unicorn_running = false unless is_ember
206
- GLib::Idle.add do
207
- update_tab_labels if @notebook
208
- 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
209
240
  end
210
241
  end
211
242
 
212
- # Monitor stdout
213
- Thread.new do
214
- while line = stdout.gets
215
- buffer = command.include?("ember-cli") ? @ember_output : @unicorn_output
216
- puts "[OUT] #{line}" if OPTIONS[:debug]
217
- buffer << line
218
- buffer.shift if buffer.size > BUFFER_SIZE
219
- # Force GUI update
220
- GLib::Idle.add do
221
- update_all_views
222
- 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
223
257
  end
224
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
+ }
225
268
  end
226
269
 
227
- # Monitor stderr
228
- Thread.new do
229
- while line = stderr.gets
230
- buffer = command.include?("ember-cli") ? @ember_output : @unicorn_output
231
- puts "[ERR] #{line}" if OPTIONS[:debug]
232
- buffer << line
233
- buffer.shift if buffer.size > BUFFER_SIZE
234
- # Force GUI update
235
- GLib::Idle.add do
236
- update_all_views
237
- 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
238
283
  end
284
+ return
239
285
  end
240
- end
241
286
 
242
- {
243
- pid: wait_thr.pid,
244
- stdin: stdin,
245
- stdout: stdout,
246
- stderr: stderr,
247
- thread: wait_thr,
248
- monitor: monitor_thread
249
- }
250
- end
287
+ # Clean up any existing window
288
+ if @status_window
289
+ @status_window.destroy
290
+ @status_window = nil
291
+ end
251
292
 
252
- def show_status_window
253
- if @status_window&.visible?
254
- @status_window.present
255
- # Force window to current workspace in i3
256
- if @status_window.window
257
- @status_window.window.raise
258
- if system("which i3-msg >/dev/null 2>&1")
259
- # First move to current workspace, then focus
260
- system(
261
- "i3-msg '[id=#{@status_window.window.xid}] move workspace current'"
262
- )
263
- system("i3-msg '[id=#{@status_window.window.xid}] focus'")
264
- 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
265
314
  end
266
- return
267
- end
268
315
 
269
- # Clean up any existing window
270
- if @status_window
271
- @status_window.destroy
272
- @status_window = nil
273
- 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
274
321
 
275
- @status_window = Gtk::Window.new("Discourse Status")
276
- @status_window.set_wmclass("discourse-status", "Discourse Status")
277
-
278
- # Load saved geometry or use defaults
279
- config = self.class.load_config
280
- if config["window_geometry"]
281
- geo = config["window_geometry"]
282
- @status_window.move(geo["x"], geo["y"])
283
- @status_window.resize(geo["width"], geo["height"])
284
- else
285
- @status_window.set_default_size(800, 600)
286
- @status_window.window_position = :center
287
- end
288
- @status_window.type_hint = :dialog
289
- @status_window.set_role("discourse-status-dialog")
290
-
291
- # Handle window destruction and hide
292
- @status_window.signal_connect("delete-event") do
293
- save_window_geometry
294
- @status_window.hide
295
- true # Prevent destruction
296
- end
322
+ @notebook = Gtk::Notebook.new
297
323
 
298
- # Save position and size when window is moved or resized
299
- @status_window.signal_connect("configure-event") do
300
- save_window_geometry
301
- false
302
- 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)
303
327
 
304
- @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)
305
331
 
306
- @ember_view = create_log_view(@ember_output)
307
- @ember_label = create_status_label("Ember CLI", @ember_running)
308
- @notebook.append_page(@ember_view, @ember_label)
332
+ @status_window.add(@notebook)
333
+ @status_window.show_all
334
+ end
309
335
 
310
- @unicorn_view = create_log_view(@unicorn_output)
311
- @unicorn_label = create_status_label("Unicorn", @unicorn_running)
312
- @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?
313
342
 
314
- @status_window.add(@notebook)
315
- @status_window.show_all
316
- 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
317
354
 
318
- def update_all_views
319
- return unless @status_window && !@status_window.destroyed?
320
- return unless @ember_view && @unicorn_view
321
- return unless @ember_view.child && @unicorn_view.child
322
- return if @ember_view.destroyed? || @unicorn_view.destroyed?
323
- return if @ember_view.child.destroyed? || @unicorn_view.child.destroyed?
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
324
390
 
325
- begin
326
- if @ember_view.visible? && @ember_view.child.visible?
327
- update_log_view(@ember_view.child, @ember_output)
328
- end
329
- if @unicorn_view.visible? && @unicorn_view.child.visible?
330
- update_log_view(@unicorn_view.child, @unicorn_output)
391
+ @view_timeouts[text_view.object_id] = timeout_id
392
+
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
331
402
  end
332
- rescue StandardError => e
333
- puts "Error updating views: #{e}" if OPTIONS[:debug]
334
- end
335
- end
336
403
 
337
- def create_log_view(buffer)
338
- scroll = Gtk::ScrolledWindow.new
339
- text_view = Gtk::TextView.new
340
- text_view.editable = false
341
- text_view.wrap_mode = :word
404
+ scroll.add(text_view)
405
+ scroll
406
+ end
342
407
 
343
- # Set white text on black background
344
- text_view.override_background_color(:normal, Gdk::RGBA.new(0, 0, 0, 1))
345
- text_view.override_color(:normal, Gdk::RGBA.new(1, 1, 1, 1))
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
346
421
 
347
- # Create text tags for colors
348
- _tag_table = text_view.buffer.tag_table
349
- create_ansi_tags(text_view.buffer)
422
+ # Add more tags for bold, etc
423
+ buffer.create_tag("bold", weight: :bold)
424
+ end
350
425
 
351
- # Initial text
352
- 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
353
482
 
354
- # Store timeouts in instance variable for proper cleanup
355
- @view_timeouts ||= {}
483
+ # Update our offset counter
484
+ instance_variable_set(offset_var, buffer.size)
356
485
 
357
- # Set up periodic refresh with validity check
358
- timeout_id =
359
- GLib::Timeout.add(1000) do
360
- if text_view&.parent.nil? || !text_view&.parent&.visible?
361
- @view_timeouts.delete(text_view.object_id)
362
- 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
363
490
  else
364
- begin
365
- update_log_view(text_view, buffer)
366
- rescue StandardError
367
- nil
368
- end
369
- true # Keep the timeout active
491
+ adj.value = old_value
370
492
  end
371
493
  end
494
+ end
372
495
 
373
- @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?
374
520
 
375
- # Clean up timeout when view is destroyed
376
- text_view.signal_connect("destroy") do
377
- 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
378
526
  begin
379
- GLib::Source.remove(timeout_id)
380
- rescue StandardError
381
- 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]
382
539
  end
383
540
  end
384
541
  end
385
542
 
386
- scroll.add(text_view)
387
- scroll
388
- 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
389
548
 
390
- def create_ansi_tags(buffer)
391
- # Basic ANSI colors
392
- {
393
- "31" => "#ff6b6b", # Brighter red
394
- "32" => "#87ff87", # Brighter green
395
- "33" => "#ffff87", # Brighter yellow
396
- "34" => "#87d7ff", # Brighter blue
397
- "35" => "#ff87ff", # Brighter magenta
398
- "36" => "#87ffff", # Brighter cyan
399
- "37" => "#ffffff" # White
400
- }.each do |code, color|
401
- 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
+ )
402
558
  end
403
559
 
404
- # Add more tags for bold, etc
405
- buffer.create_tag("bold", weight: :bold)
406
- 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
407
565
 
408
- def update_log_view(text_view, buffer)
409
- return if buffer.empty? || text_view.nil? || text_view.destroyed?
410
- return unless text_view.visible? && text_view.parent&.visible?
411
- return if text_view.buffer.nil? || text_view.buffer.destroyed?
412
-
413
- text_view.buffer.text = ""
414
- iter = text_view.buffer.get_iter_at(offset: 0)
415
-
416
- buffer.each do |line|
417
- # Parse ANSI sequences
418
- segments = line.scan(/\e\[([0-9;]*)m([^\e]*)|\e\[K([^\e]*)|([^\e]+)/)
419
-
420
- segments.each do |codes, text, clear_line, plain|
421
- if codes
422
- codes
423
- .split(";")
424
- .each do |code|
425
- case code
426
- when "1"
427
- text_view.buffer.apply_tag("bold", iter, iter)
428
- when "31".."37"
429
- text_view.buffer.apply_tag("ansi_#{code}", iter, iter)
430
- end
431
- end
432
- text_view.buffer.insert(iter, text)
433
- elsif clear_line
434
- text_view.buffer.insert(iter, clear_line)
435
- else
436
- 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
437
588
  end
438
589
  end
590
+
591
+ {
592
+ pid: wait_thr.pid,
593
+ stdin: stdin,
594
+ stdout: stdout,
595
+ stderr: stderr,
596
+ thread: wait_thr
597
+ }
439
598
  end
440
599
 
441
- # Scroll to bottom if near bottom
442
- if text_view&.parent&.vadjustment
443
- adj = text_view.parent.vadjustment
444
- if adj.value >= adj.upper - adj.page_size - 50
445
- 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
446
613
  end
614
+ false
447
615
  end
448
- end
449
616
 
450
- def create_status_label(text, running)
451
- box = Gtk::Box.new(:horizontal, 5)
452
- label = Gtk::Label.new(text)
453
- status = Gtk::Label.new
454
- color =
455
- (
456
- if running
457
- Gdk::RGBA.new(0.2, 0.8, 0.2, 1)
458
- else
459
- 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
460
673
  end
461
- )
462
- status.override_color(:normal, color)
463
- status.text = running ? "●" : "○"
464
- box.pack_start(label, expand: false, fill: false, padding: 0)
465
- box.pack_start(status, expand: false, fill: false, padding: 0)
466
- box.show_all
467
- box
468
- end
674
+ else
675
+ return if self.class.running?
469
676
 
470
- def update_tab_labels
471
- return unless @notebook
472
- @ember_label.children[1].text = @ember_running ? "●" : "○"
473
- @ember_label.children[1].override_color(
474
- :normal,
475
- Gdk::RGBA.new(
476
- @ember_running ? 0.2 : 0.8,
477
- @ember_running ? 0.8 : 0.2,
478
- 0.2,
479
- 1
480
- )
481
- )
482
-
483
- @unicorn_label.children[1].text = @unicorn_running ? "●" : "○"
484
- @unicorn_label.children[1].override_color(
485
- :normal,
486
- Gdk::RGBA.new(
487
- @unicorn_running ? 0.2 : 0.8,
488
- @unicorn_running ? 0.8 : 0.2,
489
- 0.2,
490
- 1
491
- )
492
- )
493
- end
677
+ system("mkfifo #{PIPE_PATH}") unless File.exist?(PIPE_PATH)
494
678
 
495
- def save_window_geometry
496
- 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)
497
682
 
498
- x, y = @status_window.position
499
- 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
500
692
 
501
- self.class.save_config(
502
- path: @discourse_path,
503
- window_geometry: {
504
- "x" => x,
505
- "y" => y,
506
- "width" => width,
507
- "height" => height
508
- }
509
- )
510
- end
693
+ # Initialize GTK
694
+ Gtk.init
511
695
 
512
- def set_icon(status)
513
- icon_file = status == :running ? "discourse_running.png" : "discourse.png"
514
- icon_path = File.join(File.dirname(__FILE__), "../../assets", icon_file)
515
- @indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: icon_path)
516
- end
696
+ # Setup systray icon and menu
697
+ init_systray
517
698
 
518
- def run
519
- 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
520
717
  end
521
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.1
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