discourse-systray 0.1.1 → 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: 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