2
# Copyright 2009 Martin Owens
4
# This program is free software: you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program. If not, see <http://www.gnu.org/licenses/>
18
This is a simple extension for nautilus to allow project integration.
29
from urlparse import urlparse
30
from urllib import url2pathname
31
from bzrlib import errors
33
from GroundControl.projects import ProjectSelection, Project, ProjectCreateApp
34
from GroundControl.bugs import BugSelection
35
from GroundControl.branches import BranchSelection
36
from GroundControl.commiter import CommitChanges, RevertChanges
37
from GroundControl.butties import get_functions
38
from GroundControl.gtkviews import IconManager
39
from GroundControl.helper import HelpApp
40
from GroundControl.merger import RequestMergeApp, merge_url
41
from GroundControl.bazaar import (
42
BzrBranch, CommitStatusApp, PushStatusApp, BranchStatusApp,
43
CheckoutStatusApp, PullStatusApp, RevertStatusApp, MergeStatusApp,
46
from GroundControl.configuration import ConfigGui
47
from GroundControl.base import (
48
PROJECT_XDG, PROJECT_NAME,
49
is_online, set_online_changed
51
from GroundControl import __appname__, __version__
52
from GroundControl.launchpad import DEFAULT_CRED
54
# Do not use launchpadlib within this main thread
55
# It's not thread safe and causes lots of errors.
57
print _("Initializing %s-%s extension" % (__appname__, __version__))
59
HOME_DIR = os.path.expanduser('~/')
60
STATUS_ICONS = IconManager('')
62
class GroundControlBar(nautilus.LocationWidgetProvider, nautilus.InfoProvider):
63
"""Project management extension class"""
68
self._change_watch = False
69
self._file_changed = False
70
set_online_changed(self.update_widget)
73
def _reset_vars(self):
74
"""Set some basic variables"""
83
"""Get the status of the Launchpad configuration"""
84
return os.path.exists(DEFAULT_CRED % PROJECT_NAME)
86
def get_widget(self, url, window):
88
Returns either None or a Gtk widget to decorate the Nautilus
89
window with, based on whether the current directory is a storage
93
parsed_url = urlparse(url)
95
self._online = is_online()
98
if parsed_url.scheme == "file" and parsed_url.path:
99
path = url2pathname(parsed_url.path)
101
self._widget = BarWidget(self, online=self._online)
102
self._project = self.get_project(path)
104
# Cancel any monitoring
105
self.moniter_directory(None)
106
# Convience variable for widget
108
config = os.path.join(path, '.groundcontrol')
110
# Create the default file on contact, this isn't ideal
111
# But it prepares the ground for more interesting features
112
if path == os.path.join(HOME_DIR, _('Projects')):
113
if not os.path.exists(config):
114
fhl = open(config, 'w')
118
# First part, the projects directory
119
if os.path.exists(config):
120
# Pre-functionality, check to see if our stuff works
122
msg = _("Please Enter Account Details")
124
msg = _("Account Details Not Available")
125
bar.new_mode('configure', msg)
126
bar.button(_("Configure Launchpad"), self.configure_gui)
127
bar.icon('launchpad')
130
bar.new_mode('projects', _("Your Projects"))
131
bar.button(_("Fetch Project"), self.project_gui)
132
bar.button(_("Fix Bug"), self.bugfix_gui)
133
bar.icon('groundcontrol')
136
# Second part, an actual project
138
if self._project.broken:
139
# This means the file borked and needs to be regenerated
140
bar.new_mode('project', "Project Configuration Corrupted")
142
bar.button(_("Get New Data"), self._project.regenerate,
143
self._window, self.update_widget)
144
bar.icon('groundcontrol')
146
bar.new_mode('project', "%s" % self._project.name)
148
bar.button(_("Fetch Branch"), self.branch_gui)
149
bar.button(_("Fix Bug"), self.bugfix_gui,
151
bar.icon(self._project.logo())
154
# Third part, inside a code branch
155
if os.path.exists(os.path.join(path, '.bzr')):
157
return self.bazaar_choices(path, bar)
158
except errors.BzrError, error:
160
logging.warn("Found a broken bazaar branch! %s" % message)
161
bar.new_mode('broken', _('Broken Bzr Branch %s' % message))
166
def hide_widget(self):
167
"""We always need a bar widget, but we sometimes need to hide it"""
168
#logging.debug("TRYING TO HIDE %s" % self._widget.get_parent())
169
self._widget.get_parent().hide()
171
def update_widget(self, widget=None):
172
"""Refresh the bar widget"""
176
oldparent = self._widget.get_parent()
177
self._widget.destroy()
178
logging.debug("Updated: Getting a new Nautilus widget")
179
new_widget = self.get_widget(self._url, self._window)
181
oldparent.pack_start(new_widget)
186
def get_project(self, path):
187
"""Get project information for a dir"""
193
def configure_gui(self, widget=None):
194
"""Reconfigure the launchpad oauth login"""
198
callback=self.update_widget,
200
except Exception, message:
201
logging.error(_("Error in config GUI: %s") % message)
203
def project_gui(self, widget=None):
204
"""Calls up the project selection gui"""
205
ProjectSelection(self._path,
206
callback=self.do_project_create,
210
def do_project_create(self, project):
211
"""Create a new project with our project object"""
212
ProjectCreateApp(path=self._path,
217
def bugfix_gui(self, widget=None, project=None):
218
"""Calls up the bug selection gui"""
219
BugSelection(project, ensure_project=self._path,
220
callback=self.create_bugfix,
224
def create_bugfix(self, bug, project=None):
225
"""Prepare to fix a bug we've selected"""
226
self._working_bug = bug
229
path = os.path.join(self._path, bug.project_id)
230
if os.path.exists(path):
231
# Load project without touching bug.project because that
232
# would take more time to load the object and it's attr.
233
project = Project(path)
235
# Error because this shouldn't happen
236
raise Exception("Couldn't create projects directory!!")
237
# We need a generated workname and branch name
238
lpname = 'lp:%s' % project.pid
239
workname = 'bugfix-lp-%s' % bug.id
240
# At first we try a default development focus
242
branch = BzrBranch(lpname)
243
logging.debug("Found development target %s" % branch.url)
244
except errors.InvalidURL:
245
# Now that we know we don't have a development focus
246
# Try and ask the user to choose a branch.
247
BranchSelection(project.pid, path,
250
callback=self.create_fix_branch,
253
# Branch a new branch for our code using the development focus
254
self.create_fix_branch(lpname=lpname, path=path, workname=workname)
256
def create_fix_branch(self, **kwargs):
257
"""A special fix branch which is labeled as such"""
258
path = os.path.join(kwargs['path'], kwargs['workname'])
259
self.do_branch(**kwargs)
260
fixes = self._working_bug.id
261
title = self._working_bug.name
262
bra = BzrBranch(path)
263
bra.config.set_user_option('fixes', fixes)
264
bra.config.set_user_option('bug_title', title)
266
def branch_gui(self, widget=None):
267
"""Load the Branch Selecter GUI"""
268
if self._project.pid:
269
BranchSelection(self._project.pid, self._path,
271
callback=self.do_branch,
274
def do_branch(self, **kwargs):
275
"""Load a new window to deal with branching."""
276
path = kwargs.pop('path', self._path)
277
lpname = kwargs.pop('lpname')
278
workname = kwargs.pop('workname')
279
# No need to callback to refresh, we're in the wrong dir
281
raise Exception("Can't fetch code, no branch specified")
284
logging.debug("Branching Code...")
285
BranchStatusApp(branch=lpname, path=path,
290
logging.debug("Checking-out Code...")
291
CheckoutStatusApp(branch=lpname, path=path,
295
def bazaar_choices(self, path, widget):
296
"""Decide what choices to give to users inside a bazaar project"""
297
brch = BzrBranch(path)
298
project = self.get_project(os.path.dirname(path))
299
self.moniter_directory(path)
300
# Decide if we have a branch checkout (it has a push setup)
301
if brch.has_branching() and brch.branch.get_push_location():
302
fixes = brch.config.get_user_option('fixes')
303
bugtitle = brch.config.get_user_option('bug_title')
304
changes = brch.get_changes()
305
logging.debug("Branch locked: %s" % brch.is_locked())
307
widget.new_mode('code-locked', _("Branch Locked"))
308
widget.button(_("Unlock"), self.unlock_gui,
310
# Elsif there are non-commited changes
311
elif changes.has_changed():
312
widget.new_mode('code-modified', _("Files Modified"))
313
widget.button(_("Commit Changes"), self.commit_gui,
314
brch, project, offline=True)
315
widget.button(_("Revert Changes"), self.revert_gui,
317
# Elsif there are new files, we all want to commit them.
318
elif brch.has_newfiles():
319
widget.new_mode('code-modified', _("New Files"))
320
widget.button(_("Commit New Files"), self.commit_gui,
321
brch, project, offline=True)
322
widget.button(_("Revert Changes"), self.revert_gui,
324
# Elseif there is a difference between remote and local versions
325
elif brch.has_commits():
326
widget.new_mode('code-commited', _("Local Changes"))
327
widget.button(_("Upload Changes"), self.do_push, brch)
328
widget.button(_("Update"), self.do_resync, brch)
329
# Elsif there has already been a merge request posted.
330
elif brch.merge_revno():
331
widget.new_mode('code-mergereq', _("Merge Requested"))
332
widget.button(_("View Request"), self.view_merge, brch)
333
#widget.button("Update Status", self.update_merge, brch)
334
# Elsif there is no difference, but it's bigger
335
# than the original version
336
elif brch.has_pushes() and brch.is_child():
337
widget.new_mode('code-uploaded',
338
_("Uploaded - Merge Required"))
339
widget.button(_("Request Merge"), self.do_merge_request, brch)
340
widget.button(_("Update"), self.do_resync, brch)
342
# If there is nothing to do, then don't display anything
343
#gobject.timeout_add( 200, self.hide_widget )
344
# But there is something to do, do an update to trunk
345
widget.new_mode('code-none', _("Code Branch"))
346
widget.button(_("Update"), self.do_resync, brch)
347
if project and not brch.is_child():
348
widget.button(_("Merge In"), self.merge_gui, brch, project)
349
# We only want to display a single response for bug fixes
350
# Except for when we have done our merge request.
351
if fixes and not brch.merge_revno():
352
mode = widget._mode_id
353
title = "[lp:%s] %s" % (fixes, bugtitle)
354
if mode == 'code-none':
355
widget.new_mode('code-none', title)
356
elif mode != 'code-mergereq':
357
widget.new_mode('code-modified', title)
358
widget.button(_("Upload Fix"), self.submitfix_gui,
359
brch, project, fixes)
360
widget.icon('fix-bug')
363
widget.new_mode('code-checkout', _("<b>Read-Only Branch</b>"))
364
widget.button(_("Update to Latest"), self.do_resync, brch)
365
self.add_custom_functions(widget, path)
366
widget.icon('bazaar')
369
def unlock_gui(self, widget, branch):
370
"""Show the confirm commit dialog"""
371
UnlockStatusApp(callback=self.update_widget,
376
def submitfix_gui(self, widget, branch, project, fixing):
377
"""Show the confirm commit dialog"""
378
if branch.get_changes().has_changed():
379
# Only commit if we have changes to commit
380
CommitChanges(branch=branch, project=project, fixing=fixing,
381
callback=self.do_submitfix,
385
# Move on to what's needed to do
386
self.do_mergefix(branch)
388
def do_submitfix(self, branch, **kwargs):
389
"""Submit a fix by commiting then merge the fix"""
390
self.do_commit(branch=branch, **kwargs)
391
self.do_mergefix(branch)
393
def do_mergefix(self, branch):
394
"""Make a merge request if we don't already have one"""
395
if not branch.merge_revno():
396
self.do_push(None, branch)
397
self.do_merge_request(None, branch)
399
self.do_push(None, branch)
401
def commit_gui(self, widget, branch, project):
402
"""Show the confirm commit dialog"""
403
CommitChanges(branch=branch, project=project,
404
callback=self.do_commit,
408
def do_commit(self, **kwargs):
409
"""What to do after we've commited"""
410
CommitStatusApp(callback=self.update_widget,
415
def revert_gui(self, widget, branch):
416
"""Ask if we should revert the changes."""
417
RevertChanges(branch=branch,
419
callback=self.do_revert,
422
def do_revert(self, **kwargs):
423
"""Now actually revert the branch"""
424
RevertStatusApp(callback=self.update_widget,
429
def do_resync(self, widget, branch):
430
"""Do a pull and then update out widget"""
431
PullStatusApp(branch=branch,
432
callback=self.update_widget,
436
def do_push(self, widget, branch):
437
"""Do a push and then update our widget"""
438
PushStatusApp(branch=branch,
439
callback=self.update_widget,
443
def do_merge_request(self, widget, branch):
444
"""Open the gui for creating a merge request"""
445
RequestMergeApp(branch,
447
callback=self.update_widget,
450
def view_merge(self, widget, branch):
451
"""Somehow view the merge request, maybe open up the web browser"""
452
webbrowser.open(merge_url(branch), autoraise=1)
454
def update_merge(self, widget, branch):
455
"""Updates the merge request to check if it's done or not."""
456
logging.warn(_("Functionality not written yet"))
458
def merge_gui(self, widget, branch, project):
459
"""Show a list of branches for which to merge into this one"""
461
BranchSelection(project.pid, self._path,
465
callback=self.do_merge,
468
def do_merge(self, **kwargs):
469
"""Merge the given branch with the branch at path"""
470
path = kwargs.pop('path', self._path)
471
lpname = kwargs.pop('lpname')
472
MergeStatusApp(branch=path, source=lpname,
473
callback=self.update_widget,
477
def update_file_info(self, file):
478
"""will hopefully let us update icons and such"""
479
if self._widget and self._widget._mode_id == 'projects':
480
if file.get_uri_scheme() == 'file':
481
path = url2pathname(urlparse(file.get_uri()).path)
482
project = self.get_project(path)
483
if project and not project.broken:
484
file.add_emblem('package')
485
#file.add_string_attribute('custom_icon', '.logo.png')
486
logging.debug("Set emblem %s" % str(project.name))
488
def moniter_directory(self, path):
489
"""Moniter one directory for changes, cancel any previous"""
491
self._giomon.cancel()
493
gfile = gio.file_parse_name(path)
494
self._giomon = gfile.monitor_directory()
495
self._giomon.connect("changed", self.file_changed)
496
if not self._change_watch:
497
gobject.timeout_add( 1000, self.check_file_changes )
498
self._change_watch = True
500
def file_changed(self, filemonitor, file, other_file, event_type):
501
"""Event for when files have changed"""
502
self._file_changed = True
504
def check_file_changes(self):
505
"""This is make changes more course and not so many of them"""
506
if self._file_changed:
507
self._file_changed = False
508
logging.debug("Bazaar contents have changed - updating...")
510
if self._window and self._window.get_window():
511
gobject.timeout_add( 1000, self.check_file_changes )
513
def add_custom_functions(self, bar, path):
514
"""Add some buttons to the bar widget"""
516
for cmd in get_functions(path):
517
if cmd.is_mode(cmode):
518
ofl = cmd.opt.get('offline', 'True') == 'True'
519
button = bar.button(None, cmd.run, offline=ofl)
521
label.set_markup("<i>%s</i>" % cmd.label)
527
class BarWidget(gtk.HBox):
528
"""Basic bar widget for location widgets"""
529
def __init__(self, parent, *args, **kwargs):
530
"""Return a valid wiget for this box"""
531
self._online = kwargs.pop('online', True)
532
super(BarWidget, self).__init__(*args, **kwargs)
537
self._label = gtk.Label()
538
self._label.set_alignment(0.0, 0.5)
539
self._label.set_line_wrap(False)
540
self.pack_start(self._label, expand=True, fill=True, padding=4)
543
def new_mode(self, mode_id, label):
544
"""Clears all the existing settings and replaces the label"""
545
self._mode_id = mode_id
546
self._label.set_markup("<b>%s</b>" % label)
548
for att in self._attached:
552
self.remove(self._icon)
554
self.show_help_button()
555
gobject.timeout_add( 200, self.show_parent )
557
def show_parent(self):
558
"""show the widgets parent object"""
559
parent = self.get_parent()
561
self.get_parent().show()
563
logging.debug("Couldn't show bar parent widget - not packed yet")
565
def icon(self, iconname):
566
"""Sets a single location for an image icon on the line."""
568
self._icon = gtk.Image()
569
self.pack_start(self._icon, expand=False, fill=False, padding=4)
570
self.reorder_child(self._icon, 0)
571
pixbuf = STATUS_ICONS.get_icon(iconname)
573
endresult = pixbuf.scale_simple(32, 32, gtk.gdk.INTERP_BILINEAR)
574
self._icon.set_from_pixbuf(endresult)
577
def button(self, label, signal, *attrs, **kwargs):
578
"""Adds a new button to the bar widget and attaches a signal."""
579
offline = kwargs.get('offline', False)
580
if not (offline or self._online):
582
button = gtk.Button()
583
button.connect("clicked", signal, *attrs)
585
button.set_label(label)
587
self._attached.append(button)
588
self.pack_end(button, expand=False, fill=False, padding=4)
591
def show_help_button(self):
592
"""Add in a helpful widget that runs show_help"""
593
if HelpApp.is_help(self._mode_id):
595
pixbuf = image.render_icon(gtk.STOCK_HELP, gtk.ICON_SIZE_BUTTON)
596
image.set_from_pixbuf(pixbuf)
598
button = self.button(None, self.show_help, offline=True)
599
button.set_relief(gtk.RELIEF_NONE)
603
def show_help(self, widget=None):
604
"""Show a simple icon button that shows help when clicked"""
605
if HelpApp.is_help(self._mode_id):
606
HelpApp(self._mode_id, parent=self._bar._window, start_loop=True)