hiiro 0.1.31 → 0.1.33.pre.1
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 +4 -4
- data/README.md +4 -23
- data/TODO.md +21 -0
- data/bin/g-pr +1 -2
- data/bin/h-branch +1 -2
- data/bin/h-buffer +1 -1
- data/bin/h-link +1 -1
- data/bin/{h-subtask → h-osubtask} +1 -2
- data/bin/{h-task → h-otask} +1 -1
- data/bin/h-pane +1 -1
- data/bin/h-plugin +1 -1
- data/bin/h-pr +1 -2
- data/bin/h-session +1 -1
- data/bin/h-sha +1 -1
- data/bin/h-vim +1 -1
- data/bin/h-window +1 -1
- data/exe/h +1 -1
- data/lib/hiiro/plugins.rb +64 -0
- data/lib/hiiro/version.rb +1 -1
- data/lib/hiiro.rb +33 -18
- data/notes +224 -0
- data/plugins/{task.rb → old_task.rb} +2 -2
- data/plugins/tasks.rb +818 -0
- data/script/publish +18 -3
- metadata +11 -7
data/plugins/tasks.rb
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
WORK_DIR = File.join(Dir.home, 'work')
|
|
6
|
+
REPO_PATH = File.join(WORK_DIR, '.bare')
|
|
7
|
+
|
|
8
|
+
class TmuxSession
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
def self.current
|
|
12
|
+
return nil unless ENV['TMUX']
|
|
13
|
+
|
|
14
|
+
name = `tmux display-message -p '#S'`.chomp
|
|
15
|
+
new(name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.all
|
|
19
|
+
output = `tmux list-sessions -F '#S' 2>/dev/null`
|
|
20
|
+
output.lines(chomp: true).map { |name| new(name) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(name)
|
|
24
|
+
@name = name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other)
|
|
28
|
+
other.is_a?(TmuxSession) && name == other.name
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_s
|
|
32
|
+
name
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class Tree
|
|
37
|
+
attr_reader :path, :head, :branch
|
|
38
|
+
|
|
39
|
+
def self.all(repo_path: REPO_PATH)
|
|
40
|
+
output = `git -C #{repo_path} worktree list --porcelain 2>/dev/null`
|
|
41
|
+
|
|
42
|
+
trees = []
|
|
43
|
+
current = nil
|
|
44
|
+
|
|
45
|
+
output.lines(chomp: true).each do |line|
|
|
46
|
+
case line
|
|
47
|
+
when /^worktree (.*)/
|
|
48
|
+
trees << new(**current) if current
|
|
49
|
+
current = { path: $1 }
|
|
50
|
+
when /^HEAD (.*)/
|
|
51
|
+
current[:head] = $1 if current
|
|
52
|
+
when /^branch refs\/heads\/(.*)/
|
|
53
|
+
current[:branch] = $1 if current
|
|
54
|
+
when 'bare'
|
|
55
|
+
current = nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
trees << new(**current) if current
|
|
60
|
+
trees
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(path:, head: nil, branch: nil)
|
|
64
|
+
@path = path
|
|
65
|
+
@head = head
|
|
66
|
+
@branch = branch
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def name
|
|
70
|
+
@name ||= if path.start_with?(WORK_DIR + '/')
|
|
71
|
+
path.sub(WORK_DIR + '/', '')
|
|
72
|
+
else
|
|
73
|
+
File.basename(path)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def match?(pwd = Dir.pwd)
|
|
78
|
+
pwd == path || pwd.start_with?(path + '/')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def detached?
|
|
82
|
+
branch.nil?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ==(other)
|
|
86
|
+
other.is_a?(Tree) && path == other.path
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_s
|
|
90
|
+
name
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Task
|
|
95
|
+
attr_reader :name, :tree_name, :session_name
|
|
96
|
+
|
|
97
|
+
def initialize(name:, tree: nil, session: nil, **_)
|
|
98
|
+
@name = name
|
|
99
|
+
@tree_name = tree
|
|
100
|
+
@session_name = session || name
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parent_name
|
|
104
|
+
return nil unless subtask?
|
|
105
|
+
name.split('/').first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def short_name
|
|
109
|
+
subtask? ? name.split('/', 2).last : name
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def subtask?
|
|
113
|
+
name.include?('/')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def top_level?
|
|
117
|
+
!subtask?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ==(other)
|
|
121
|
+
other.is_a?(Task) && name == other.name
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_s
|
|
125
|
+
name
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def to_h
|
|
129
|
+
h = { 'name' => name }
|
|
130
|
+
h['tree'] = tree_name if tree_name
|
|
131
|
+
h['session'] = session_name if session_name != name
|
|
132
|
+
h
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class App
|
|
137
|
+
attr_reader :name, :relative_path
|
|
138
|
+
|
|
139
|
+
def initialize(name:, path:)
|
|
140
|
+
@name = name
|
|
141
|
+
@relative_path = path
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resolve(tree_root)
|
|
145
|
+
File.join(tree_root, relative_path)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ==(other)
|
|
149
|
+
other.is_a?(App) && name == other.name
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def to_s
|
|
153
|
+
name
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class Environment
|
|
158
|
+
attr_reader :path
|
|
159
|
+
|
|
160
|
+
def self.current
|
|
161
|
+
new(path: Dir.pwd)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def initialize(path: Dir.pwd, config: nil)
|
|
165
|
+
@path = path
|
|
166
|
+
@config = config
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def config
|
|
170
|
+
@config ||= TaskManager::Config.new
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def all_tasks
|
|
174
|
+
@all_tasks ||= config.tasks
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def all_sessions
|
|
178
|
+
@all_sessions ||= TmuxSession.all
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def all_trees
|
|
182
|
+
@all_trees ||= Tree.all
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def all_apps
|
|
186
|
+
@all_apps ||= config.apps
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def task
|
|
190
|
+
@task ||= begin
|
|
191
|
+
s = session
|
|
192
|
+
t = tree
|
|
193
|
+
all_tasks.find { |task|
|
|
194
|
+
(s && task.session_name == s.name) ||
|
|
195
|
+
(t && task.tree_name == t.name)
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def session
|
|
201
|
+
@session ||= TmuxSession.current
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def tree
|
|
205
|
+
@tree ||= all_trees.find { |t| t.match?(path) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def find_task(abbreviated)
|
|
209
|
+
return nil if abbreviated.nil?
|
|
210
|
+
|
|
211
|
+
if abbreviated.include?('/')
|
|
212
|
+
parent_prefix, child_prefix = abbreviated.split('/', 2)
|
|
213
|
+
parent = all_tasks.select(&:top_level?).find { |t| t.name.start_with?(parent_prefix) }
|
|
214
|
+
return nil unless parent
|
|
215
|
+
|
|
216
|
+
subtask = all_tasks.select { |t| t.parent_name == parent.name }.find { |t| t.short_name.start_with?(child_prefix) }
|
|
217
|
+
return subtask if subtask
|
|
218
|
+
|
|
219
|
+
# "main" refers to the parent task itself
|
|
220
|
+
return parent if 'main'.start_with?(child_prefix)
|
|
221
|
+
|
|
222
|
+
nil
|
|
223
|
+
else
|
|
224
|
+
all_tasks.find { |t| t.name.start_with?(abbreviated) }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def find_tree(abbreviated)
|
|
229
|
+
return nil if abbreviated.nil?
|
|
230
|
+
all_trees.find { |t| t.name.start_with?(abbreviated) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def find_session(abbreviated)
|
|
234
|
+
return nil if abbreviated.nil?
|
|
235
|
+
all_sessions.find { |s| s.name.start_with?(abbreviated) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def find_app(abbreviated)
|
|
239
|
+
return nil if abbreviated.nil?
|
|
240
|
+
all_apps.find { |a| a.name.start_with?(abbreviated) }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class TaskManager
|
|
245
|
+
TASKS_DIR = File.join(Dir.home, '.config', 'hiiro', 'tasks')
|
|
246
|
+
APPS_FILE = File.join(Dir.home, '.config', 'hiiro', 'apps.yml')
|
|
247
|
+
|
|
248
|
+
class Config
|
|
249
|
+
attr_reader :tasks_file, :apps_file
|
|
250
|
+
|
|
251
|
+
def initialize(tasks_file: nil, apps_file: nil)
|
|
252
|
+
@tasks_file = tasks_file || File.join(TASKS_DIR, 'tasks.yml')
|
|
253
|
+
@apps_file = apps_file || APPS_FILE
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def tasks
|
|
257
|
+
data = load_tasks
|
|
258
|
+
(data['tasks'] || []).map { |h| Task.new(**h.transform_keys(&:to_sym)) }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def apps
|
|
262
|
+
return [] unless File.exist?(apps_file)
|
|
263
|
+
data = YAML.safe_load_file(apps_file) || {}
|
|
264
|
+
data.map { |name, path| App.new(name: name, path: path) }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def save_task(task)
|
|
268
|
+
data = load_tasks
|
|
269
|
+
data['tasks'] ||= []
|
|
270
|
+
data['tasks'].reject! { |t| t['name'] == task.name }
|
|
271
|
+
data['tasks'] << task.to_h
|
|
272
|
+
save_tasks(data)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def remove_task(name)
|
|
276
|
+
data = load_tasks
|
|
277
|
+
data['tasks'] ||= []
|
|
278
|
+
data['tasks'].reject! { |t| t['name'] == name }
|
|
279
|
+
save_tasks(data)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
def load_tasks
|
|
285
|
+
if File.exist?(tasks_file)
|
|
286
|
+
return YAML.safe_load_file(tasks_file) || { 'tasks' => [] }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
assignments_file = File.join(File.dirname(tasks_file), 'assignments.yml')
|
|
290
|
+
if File.exist?(assignments_file)
|
|
291
|
+
raw = YAML.safe_load_file(assignments_file) || {}
|
|
292
|
+
tasks = raw.map do |tree_path, task_name|
|
|
293
|
+
h = { 'name' => task_name, 'tree' => tree_path }
|
|
294
|
+
h['session'] = task_name if task_name.include?('/')
|
|
295
|
+
h
|
|
296
|
+
end
|
|
297
|
+
data = { 'tasks' => tasks }
|
|
298
|
+
save_tasks(data)
|
|
299
|
+
return data
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
{ 'tasks' => [] }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def save_tasks(data)
|
|
306
|
+
FileUtils.mkdir_p(File.dirname(tasks_file))
|
|
307
|
+
File.write(tasks_file, YAML.dump(data))
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
attr_reader :hiiro, :scope, :environment
|
|
312
|
+
|
|
313
|
+
def initialize(hiiro, scope: :task, environment: nil)
|
|
314
|
+
@hiiro = hiiro
|
|
315
|
+
@scope = scope
|
|
316
|
+
@environment = environment || Environment.current
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def config
|
|
320
|
+
environment.config
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# --- Scope-aware queries ---
|
|
324
|
+
|
|
325
|
+
def tasks
|
|
326
|
+
if scope == :subtask
|
|
327
|
+
parent = current_parent_task
|
|
328
|
+
return [] unless parent
|
|
329
|
+
main_task = Task.new(name: "#{parent.name}/main", tree: parent.tree_name, session: parent.session_name)
|
|
330
|
+
subtask_list = environment.all_tasks.select { |t| t.parent_name == parent.name }
|
|
331
|
+
[main_task, *subtask_list]
|
|
332
|
+
else
|
|
333
|
+
environment.all_tasks.select(&:top_level?)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def subtasks(task)
|
|
338
|
+
environment.all_tasks.select { |t| t.parent_name == task.name }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def task_by_name(name)
|
|
342
|
+
return slash_lookup(name) if name.include?('/')
|
|
343
|
+
|
|
344
|
+
tasks.find { |t|
|
|
345
|
+
match_name = (scope == :subtask) ? t.short_name : t.name
|
|
346
|
+
match_name.start_with?(name)
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def task_by_tree(tree_name)
|
|
351
|
+
tasks.find { |t| t.tree_name == tree_name }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def task_by_session(session_name)
|
|
355
|
+
tasks.find { |t| t.session_name == session_name }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def current_task
|
|
359
|
+
environment.task
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def current_session
|
|
363
|
+
environment.session
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def current_tree
|
|
367
|
+
environment.tree
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# --- Actions ---
|
|
371
|
+
|
|
372
|
+
def start_task(name, app_name: nil)
|
|
373
|
+
existing = task_by_name(name)
|
|
374
|
+
if existing
|
|
375
|
+
puts "Task '#{existing.name}' already exists. Switching..."
|
|
376
|
+
switch_to_task(existing, app_name: app_name)
|
|
377
|
+
return
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
task_name = scope == :subtask ? "#{current_parent_task.name}/#{name}" : name
|
|
381
|
+
subtree_name = scope == :subtask ? "#{current_parent_task.name}/#{name}" : "#{name}/main"
|
|
382
|
+
|
|
383
|
+
target_path = File.join(WORK_DIR, subtree_name)
|
|
384
|
+
|
|
385
|
+
available = find_available_tree
|
|
386
|
+
if available
|
|
387
|
+
puts "Renaming worktree '#{available.name}' to '#{subtree_name}'..."
|
|
388
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
389
|
+
unless system('git', '-C', REPO_PATH, 'worktree', 'move', available.path, target_path)
|
|
390
|
+
puts "ERROR: Failed to rename worktree"
|
|
391
|
+
return
|
|
392
|
+
end
|
|
393
|
+
else
|
|
394
|
+
puts "Creating new worktree '#{subtree_name}'..."
|
|
395
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
396
|
+
unless system('git', '-C', REPO_PATH, 'worktree', 'add', '--detach', target_path)
|
|
397
|
+
puts "ERROR: Failed to create worktree"
|
|
398
|
+
return
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
session_name = task_name
|
|
403
|
+
task = Task.new(name: task_name, tree: subtree_name, session: session_name)
|
|
404
|
+
config.save_task(task)
|
|
405
|
+
|
|
406
|
+
base_dir = target_path
|
|
407
|
+
if app_name
|
|
408
|
+
app = environment.find_app(app_name)
|
|
409
|
+
base_dir = app.resolve(target_path) if app
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
Dir.chdir(base_dir)
|
|
413
|
+
hiiro.start_tmux_session(session_name)
|
|
414
|
+
|
|
415
|
+
puts "Started task '#{task_name}' in worktree '#{subtree_name}'"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def switch_to_task(task, app_name: nil)
|
|
419
|
+
unless task
|
|
420
|
+
puts "Task not found"
|
|
421
|
+
return
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
tree = environment.find_tree(task.tree_name)
|
|
425
|
+
tree_path = tree ? tree.path : File.join(WORK_DIR, task.tree_name)
|
|
426
|
+
|
|
427
|
+
session_name = task.session_name
|
|
428
|
+
session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL)
|
|
429
|
+
|
|
430
|
+
if session_exists
|
|
431
|
+
hiiro.start_tmux_session(session_name)
|
|
432
|
+
else
|
|
433
|
+
base_dir = tree_path
|
|
434
|
+
if app_name
|
|
435
|
+
app = environment.find_app(app_name)
|
|
436
|
+
base_dir = app.resolve(tree_path) if app
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
if Dir.exist?(base_dir)
|
|
440
|
+
Dir.chdir(base_dir)
|
|
441
|
+
hiiro.start_tmux_session(session_name)
|
|
442
|
+
else
|
|
443
|
+
puts "ERROR: Path '#{base_dir}' does not exist"
|
|
444
|
+
return
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
puts "Switched to '#{task.name}'"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def stop_task(task)
|
|
452
|
+
unless task
|
|
453
|
+
puts "Task not found"
|
|
454
|
+
return
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
config.remove_task(task.name)
|
|
458
|
+
# Also remove any subtasks
|
|
459
|
+
subtasks(task).each { |st| config.remove_task(st.name) }
|
|
460
|
+
|
|
461
|
+
puts "Stopped task '#{task.name}' (worktree available for reuse)"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def list
|
|
465
|
+
items = tasks
|
|
466
|
+
if items.empty?
|
|
467
|
+
puts scope == :subtask ? "No subtasks found" : "No tasks found"
|
|
468
|
+
puts "Use 'h #{scope} start NAME' to create one."
|
|
469
|
+
return
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
current = current_task
|
|
473
|
+
label = scope == :subtask ? "Subtasks" : "Tasks"
|
|
474
|
+
if scope == :subtask && current
|
|
475
|
+
parent = current_parent_task
|
|
476
|
+
label = "Subtasks of '#{parent&.name}'" if parent
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
puts "#{label}:"
|
|
480
|
+
puts
|
|
481
|
+
|
|
482
|
+
items.each do |task|
|
|
483
|
+
marker = (current && current.name == task.name) ? "*" : " "
|
|
484
|
+
tree = environment.find_tree(task.tree_name)
|
|
485
|
+
branch = tree&.branch || (tree&.detached? ? '(detached)' : nil)
|
|
486
|
+
branch_str = branch ? " [#{branch}]" : ""
|
|
487
|
+
|
|
488
|
+
display_name = scope == :subtask ? task.short_name : task.name
|
|
489
|
+
puts format("%s %-25s tree: %-20s%s", marker, display_name, task.tree_name || '(none)', branch_str)
|
|
490
|
+
|
|
491
|
+
# Show subtask count for top-level tasks
|
|
492
|
+
if scope == :task
|
|
493
|
+
subs = subtasks(task)
|
|
494
|
+
subs.each do |st|
|
|
495
|
+
sub_marker = (current && current.name == st.name) ? "*" : " "
|
|
496
|
+
sub_tree = environment.find_tree(st.tree_name)
|
|
497
|
+
sub_branch = sub_tree&.branch || (sub_tree&.detached? ? '(detached)' : nil)
|
|
498
|
+
sub_branch_str = sub_branch ? " [#{sub_branch}]" : ""
|
|
499
|
+
padding = " " * task.name.length
|
|
500
|
+
puts format("%s %s/%-*s tree: %-20s%s", sub_marker, padding, 25 - task.name.length - 1, st.short_name, st.tree_name || '(none)', sub_branch_str)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
available = environment.all_trees.reject { |t|
|
|
506
|
+
environment.all_tasks.any? { |task| task.tree_name == t.name }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if available.any?
|
|
510
|
+
puts
|
|
511
|
+
available.each do |tree|
|
|
512
|
+
branch_str = tree.branch ? " [#{tree.branch}]" : tree.detached? ? " [(detached)]" : ""
|
|
513
|
+
puts format(" %-25s (available)%s", tree.name, branch_str)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def status
|
|
519
|
+
task = current_task
|
|
520
|
+
unless task
|
|
521
|
+
puts "Not currently in a task session"
|
|
522
|
+
return
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
puts "Task: #{task.name}"
|
|
526
|
+
puts "Worktree: #{task.tree_name}"
|
|
527
|
+
tree = environment.find_tree(task.tree_name)
|
|
528
|
+
puts "Path: #{tree&.path || '(unknown)'}"
|
|
529
|
+
puts "Session: #{task.session_name}"
|
|
530
|
+
puts "Parent: #{task.parent_name}" if task.subtask?
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def save
|
|
534
|
+
task = current_task
|
|
535
|
+
unless task
|
|
536
|
+
puts "ERROR: Not currently in a task session"
|
|
537
|
+
return
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
windows = capture_tmux_windows(task.session_name)
|
|
541
|
+
puts "Saved task '#{task.name}' state (#{windows.count} windows)"
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def open_app(app_name)
|
|
545
|
+
task = current_task
|
|
546
|
+
unless task
|
|
547
|
+
puts "ERROR: Not currently in a task session"
|
|
548
|
+
return
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
result = resolve_app(app_name, task)
|
|
552
|
+
return unless result
|
|
553
|
+
|
|
554
|
+
resolved_name, app_path = result
|
|
555
|
+
system('tmux', 'new-window', '-n', resolved_name, '-c', app_path)
|
|
556
|
+
puts "Opened '#{resolved_name}' in new window (#{app_path})"
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def list_apps
|
|
560
|
+
apps = environment.all_apps
|
|
561
|
+
if apps.any?
|
|
562
|
+
puts "Configured apps:"
|
|
563
|
+
puts
|
|
564
|
+
apps.each do |app|
|
|
565
|
+
puts format(" %-20s => %s", app.name, app.relative_path)
|
|
566
|
+
end
|
|
567
|
+
else
|
|
568
|
+
puts "No apps configured."
|
|
569
|
+
puts "Create #{APPS_FILE} with format:"
|
|
570
|
+
puts " app_name: relative/path/from/repo"
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def cd_to_task(task)
|
|
575
|
+
unless task
|
|
576
|
+
puts "Task not found"
|
|
577
|
+
return
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
tree = environment.find_tree(task.tree_name)
|
|
581
|
+
path = tree ? tree.path : File.join(WORK_DIR, task.tree_name)
|
|
582
|
+
send_cd(path)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def cd_to_app(app_name = nil)
|
|
586
|
+
task = current_task
|
|
587
|
+
unless task
|
|
588
|
+
puts "ERROR: Not currently in a task session"
|
|
589
|
+
return
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
if app_name.nil? || app_name.empty?
|
|
593
|
+
tree = environment.find_tree(task.tree_name)
|
|
594
|
+
send_cd(tree&.path || File.join(WORK_DIR, task.tree_name))
|
|
595
|
+
return
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
result = resolve_app(app_name, task)
|
|
599
|
+
return unless result
|
|
600
|
+
|
|
601
|
+
_resolved_name, app_path = result
|
|
602
|
+
send_cd(app_path)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def app_path(app_name = nil)
|
|
606
|
+
task = current_task
|
|
607
|
+
tree_root = if task
|
|
608
|
+
tree = environment.find_tree(task.tree_name)
|
|
609
|
+
tree&.path || File.join(WORK_DIR, task.tree_name)
|
|
610
|
+
else
|
|
611
|
+
`git rev-parse --show-toplevel`.strip
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
if app_name.nil?
|
|
615
|
+
print tree_root
|
|
616
|
+
return
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
matches = environment.all_apps.select { |a| a.name.start_with?(app_name) }
|
|
620
|
+
|
|
621
|
+
case matches.count
|
|
622
|
+
when 0
|
|
623
|
+
puts "ERROR: No matches found"
|
|
624
|
+
puts
|
|
625
|
+
puts "Possible Apps:"
|
|
626
|
+
environment.all_apps.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
|
|
627
|
+
when 1
|
|
628
|
+
print matches.first.resolve(tree_root)
|
|
629
|
+
else
|
|
630
|
+
puts "Multiple matches found:"
|
|
631
|
+
matches.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def help
|
|
636
|
+
scope_name = scope.to_s
|
|
637
|
+
puts "Usage: h #{scope_name} <subcommand> [args]"
|
|
638
|
+
puts
|
|
639
|
+
puts "Subcommands:"
|
|
640
|
+
puts " list, ls List #{scope_name}s"
|
|
641
|
+
puts " start NAME [APP] Start a new #{scope_name}"
|
|
642
|
+
puts " switch [NAME] Switch to a #{scope_name} (interactive if no name)"
|
|
643
|
+
puts " app [APP_NAME] Open app in new tmux window (interactive if no name)"
|
|
644
|
+
puts " apps List configured apps"
|
|
645
|
+
puts " cd [APP_NAME] Change directory to app"
|
|
646
|
+
puts " path [APP_NAME] Print app path"
|
|
647
|
+
puts " status, st Show current #{scope_name} status"
|
|
648
|
+
puts " save Save current session state"
|
|
649
|
+
puts " stop [NAME] Stop a #{scope_name} (interactive if no name)"
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# --- Interactive selection with sk ---
|
|
653
|
+
|
|
654
|
+
def select_task_interactive(prompt = nil)
|
|
655
|
+
names = tasks.map { |t| scope == :subtask ? t.short_name : t.name }
|
|
656
|
+
return nil if names.empty?
|
|
657
|
+
|
|
658
|
+
sk_select(names)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# --- Private helpers ---
|
|
662
|
+
|
|
663
|
+
private
|
|
664
|
+
|
|
665
|
+
def slash_lookup(input)
|
|
666
|
+
environment.find_task(input)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def current_parent_task
|
|
670
|
+
task = current_task
|
|
671
|
+
return nil unless task
|
|
672
|
+
|
|
673
|
+
if task.subtask?
|
|
674
|
+
environment.find_task(task.parent_name)
|
|
675
|
+
else
|
|
676
|
+
task
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def find_available_tree
|
|
681
|
+
assigned_tree_names = environment.all_tasks.map(&:tree_name)
|
|
682
|
+
environment.all_trees.find { |tree| !assigned_tree_names.include?(tree.name) }
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def resolve_app(app_name, task)
|
|
686
|
+
tree = environment.find_tree(task.tree_name)
|
|
687
|
+
tree_root = tree ? tree.path : File.join(WORK_DIR, task.tree_name)
|
|
688
|
+
|
|
689
|
+
matches = environment.all_apps.select { |a| a.name.start_with?(app_name) }
|
|
690
|
+
|
|
691
|
+
case matches.count
|
|
692
|
+
when 0
|
|
693
|
+
# Fallback: directory discovery
|
|
694
|
+
exact = File.join(tree_root, app_name)
|
|
695
|
+
return [app_name, exact] if Dir.exist?(exact)
|
|
696
|
+
|
|
697
|
+
nested = File.join(tree_root, app_name, app_name)
|
|
698
|
+
return [app_name, nested] if Dir.exist?(nested)
|
|
699
|
+
|
|
700
|
+
puts "ERROR: App '#{app_name}' not found"
|
|
701
|
+
list_apps
|
|
702
|
+
nil
|
|
703
|
+
when 1
|
|
704
|
+
app = matches.first
|
|
705
|
+
[app.name, app.resolve(tree_root)]
|
|
706
|
+
else
|
|
707
|
+
exact = matches.find { |a| a.name == app_name }
|
|
708
|
+
if exact
|
|
709
|
+
[exact.name, exact.resolve(tree_root)]
|
|
710
|
+
else
|
|
711
|
+
puts "ERROR: '#{app_name}' matches multiple apps:"
|
|
712
|
+
matches.each { |a| puts " #{a.name}" }
|
|
713
|
+
nil
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def send_cd(path)
|
|
719
|
+
pane = ENV['TMUX_PANE']
|
|
720
|
+
if pane
|
|
721
|
+
system('tmux', 'send-keys', '-t', pane, "cd #{path}\n")
|
|
722
|
+
else
|
|
723
|
+
system('tmux', 'send-keys', "cd #{path}\n")
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def capture_tmux_windows(session)
|
|
728
|
+
output = `tmux list-windows -t #{session} -F '\#{window_index}:\#{window_name}:\#{pane_current_path}' 2>/dev/null`
|
|
729
|
+
output.lines.map(&:strip).map { |line|
|
|
730
|
+
idx, name, path = line.split(':')
|
|
731
|
+
{ 'index' => idx, 'name' => name, 'path' => path }
|
|
732
|
+
}
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def sk_select(items)
|
|
736
|
+
selected, status = Open3.capture2('sk', stdin_data: items.join("\n"))
|
|
737
|
+
return selected.strip if status.success? && !selected.strip.empty?
|
|
738
|
+
nil
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
module Tasks
|
|
743
|
+
def self.load(hiiro)
|
|
744
|
+
hiiro.load_plugin(Tmux)
|
|
745
|
+
add_subcommands(hiiro)
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def self.add_subcommands(hiiro)
|
|
749
|
+
hiiro.add_subcmd(:task) do |*args|
|
|
750
|
+
mgr = TaskManager.new(hiiro, scope: :task)
|
|
751
|
+
task_hiiro = Tasks.build_hiiro(hiiro, mgr)
|
|
752
|
+
task_hiiro.run
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
hiiro.add_subcmd(:subtask) do |*args|
|
|
756
|
+
mgr = TaskManager.new(hiiro, scope: :subtask)
|
|
757
|
+
task_hiiro = Tasks.build_hiiro(hiiro, mgr)
|
|
758
|
+
task_hiiro.run
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def self.build_hiiro(parent_hiiro, mgr)
|
|
763
|
+
Hiiro.init(*parent_hiiro.args, mgr: mgr) do |h|
|
|
764
|
+
h.add_subcmd(:list) { mgr.list }
|
|
765
|
+
h.add_subcmd(:ls) { mgr.list }
|
|
766
|
+
|
|
767
|
+
h.add_subcmd(:start) do |task_name, app_name=nil|
|
|
768
|
+
mgr.start_task(task_name, app_name: app_name)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
h.add_subcmd(:switch) do |task_name=nil, app_name=nil|
|
|
772
|
+
if task_name.nil?
|
|
773
|
+
task_name = mgr.select_task_interactive
|
|
774
|
+
return unless task_name
|
|
775
|
+
end
|
|
776
|
+
task = mgr.task_by_name(task_name)
|
|
777
|
+
mgr.switch_to_task(task, app_name: app_name)
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
h.add_subcmd(:app) do |app_name=nil|
|
|
781
|
+
if app_name.nil?
|
|
782
|
+
names = mgr.environment.all_apps.map(&:name)
|
|
783
|
+
app_name = mgr.send(:sk_select, names)
|
|
784
|
+
return unless app_name
|
|
785
|
+
end
|
|
786
|
+
mgr.open_app(app_name)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
h.add_subcmd(:apps) { mgr.list_apps }
|
|
790
|
+
|
|
791
|
+
h.add_subcmd(:cd) do |app_name=nil|
|
|
792
|
+
mgr.cd_to_app(app_name)
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
h.add_subcmd(:path) do |app_name=nil|
|
|
796
|
+
mgr.app_path(app_name)
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
h.add_subcmd(:status) { mgr.status }
|
|
800
|
+
h.add_subcmd(:st) { mgr.status }
|
|
801
|
+
|
|
802
|
+
h.add_subcmd(:save) { mgr.save }
|
|
803
|
+
|
|
804
|
+
h.add_subcmd(:stop) do |task_name=nil|
|
|
805
|
+
if task_name.nil?
|
|
806
|
+
task_name = mgr.select_task_interactive
|
|
807
|
+
return unless task_name
|
|
808
|
+
end
|
|
809
|
+
task = mgr.task_by_name(task_name)
|
|
810
|
+
mgr.stop_task(task)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
h.add_subcmd(:edit) do
|
|
814
|
+
system(ENV['EDITOR'] || 'nvim', __FILE__)
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
end
|