Merge http://www.cl.cam.ac.uk/~maw48/gct.git
[hgct.git] / main.py
blobb9325af5b545dbaf4343c751e24350bed474172d
1 #!/usr/bin/env python
3 # Copyright (c) 2005 Fredrik Kuivinen <freku045@student.liu.se>
4 # Copyright (c) 2005 Mark Williamson <mark.williamson@cl.cam.ac.uk>
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License version 2 as
8 # published by the Free Software Foundation.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 import sys, math, random, qt, os, re, signal, sets, settings
20 from optparse import OptionParser
22 from ctcore import *
24 # Determine semantics according to executable name. Default to git.
25 if os.path.basename(sys.argv[0]) == 'hgct':
26 print "defaulting to import hg because arg = %s" % sys.argv[0]
27 import hg as scm
28 else:
29 import git as scm
31 qconnect = qt.QObject.connect
32 Qt = qt.Qt
33 #DEBUG = 1
35 class CommitError(Exception):
36 def __init__(self, operation, msg):
37 self.operation = operation
38 self.msg = msg
40 class FileState:
41 pass
43 class MyListItem(qt.QCheckListItem):
44 def __init__(self, parent, file, commitMsg = False):
45 qt.QCheckListItem.__init__(self, parent, file.text, qt.QCheckListItem.CheckBox)
46 self.file = file
47 self.commitMsg = commitMsg
49 def compare(self, item, col, asc):
50 if self.commitMsg:
51 if asc:
52 return -1
53 else:
54 return 1
55 elif item.commitMsg:
56 if asc:
57 return 1
58 else:
59 return -1
60 else:
61 return cmp(self.file.srcName, item.file.srcName)
63 def paintCell(self, p, cg, col, w, a):
64 if self.commitMsg:
65 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
66 else:
67 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
69 def isSelected(self):
70 return self.state() == qt.QCheckListItem.On
72 def setSelected(self, s):
73 if s:
74 self.setState(qt.QCheckListItem.On)
75 else:
76 self.setState(qt.QCheckListItem.Off)
78 class MyListView(qt.QListView):
79 def __init__(self, parent=None, name=None):
80 qt.QListView.__init__(self, parent, name)
82 def __iter__(self):
83 return ListViewIterator(self)
85 class ListViewIterator:
86 def __init__(self, listview):
87 self.it = qt.QListViewItemIterator(listview)
89 def next(self):
90 cur = self.it.current()
91 if cur:
92 self.it += 1
93 if cur.commitMsg:
94 return self.next()
95 else:
96 return cur
97 else:
98 raise StopIteration()
100 def __iter__(self):
101 return self
103 class MainWidget(qt.QMainWindow):
104 def __init__(self, parent=None, name=None):
105 qt.QMainWindow.__init__(self, parent, name)
106 self.setCaption(applicationName)
108 splitter = qt.QSplitter(Qt.Vertical, self)
109 self.setCentralWidget(splitter)
110 self.splitter = splitter
112 # The file list and file filter widgets are part of this layout widget.
113 self.filesLayout = qt.QVBox(splitter)
115 # The file list
116 fW = MyListView(self.filesLayout)
117 self.filesW = fW
118 fW.setFocus()
119 fW.setSelectionMode(qt.QListView.NoSelection)
120 fW.addColumn('Description')
121 fW.setResizeMode(qt.QListView.AllColumns)
123 # The file filter
124 self.filterLayout = qt.QHBox(self.filesLayout)
125 self.filterLabel = qt.QLabel("File filter: ", self.filterLayout)
126 self.filter = qt.QLineEdit(self.filterLayout)
127 self.filterLabel.setBuddy(self.filter)
129 qconnect(self.filter, qt.SIGNAL("textChanged(const QString&)"), self.updateFilter)
131 self.newCurLambda = lambda i: self.currentChange(i)
132 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
134 # The diff viewing widget
135 self.text = qt.QWidgetStack(splitter)
137 ops = qt.QPopupMenu(self)
138 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
139 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
140 ops.insertItem("Select All", self.selectAll, Qt.CTRL+Qt.Key_A)
141 ops.insertItem("Unselect All", self.unselectAll, Qt.CTRL+Qt.Key_U)
142 ops.insertItem("Preferences...", self.showPrefs, Qt.CTRL+Qt.Key_P)
144 m = self.menuBar()
145 m.insertItem("&Operations", ops)
147 h = qt.QPopupMenu(self)
148 h.insertItem("&About", self.about)
149 m.insertItem("&Help", h)
151 qconnect(fW, qt.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
152 self.contextMenuRequestedSlot)
153 self.fileOps = qt.QPopupMenu(self)
154 self.fileOps.insertItem("Toggle selection", self.toggleFile)
155 self.fileOps.insertItem("Edit", self.editFile, Qt.CTRL+Qt.Key_E)
156 self.fileOps.insertItem("Discard changes", self.discardFile)
157 self.fileOps.insertItem("Ignore file", self.ignoreFile)
159 # The following attribute is set by contextMenuRequestedSlot
160 # and currentChange and used by the fileOps
161 self.currentContextItem = None
163 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
165 self.files = []
167 f = File()
168 f.text = "Commit message"
169 f.textW = self.newTextEdit()
170 f.textW.setTextFormat(Qt.PlainText)
171 f.textW.setReadOnly(False)
172 f.textW.setText(settings.signoff)
174 self.cmitFile = f
175 self.createCmitItem()
176 self.editorProcesses = sets.Set()
177 self.loadSettings()
179 def loadSettings(self):
180 self.splitter.setSizes(settings.splitter)
182 def closeEvent(self, e):
183 s = self.size()
184 settings.width = s.width()
185 settings.height = s.height()
186 settings.splitter = self.splitter.sizes()
187 e.accept()
189 def createCmitItem(self):
190 self.cmitItem = MyListItem(self.filesW, self.cmitFile, True)
191 self.cmitItem.setSelectable(False)
192 self.filesW.insertItem(self.cmitItem)
193 self.cmitFile.listViewItem = self.cmitItem
195 def about(self, ignore):
196 qt.QMessageBox.about(self, "About " + applicationName,
197 "<qt><center><h1>" + applicationName + " " + version + """</h1></center>\n
198 <center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;
199 </center>\n<p>This program is free software; you can redistribute it and/or
200 modify it under the terms of the GNU General Public License version 2 as
201 published by the Free Software Foundation.</p></qt>""")
203 def contextMenuRequestedSlot(self, item, pos, col):
204 if item and not item.commitMsg:
205 self.currentContextItem = item
206 self.fileOps.exec_loop(qt.QCursor.pos())
207 else:
208 self.currentContextItem = None
210 def toggleFile(self, ignored):
211 it = self.currentContextItem
212 if not it:
213 return
215 if it.isSelected():
216 it.setSelected(False)
217 else:
218 it.setSelected(True)
220 def editFile(self, ignored):
221 it = self.currentContextItem
222 if not it:
223 return
225 ed = getEditor()
226 if not ed:
227 qt.QMessageBox.warning(self, 'No editor found',
228 '''No editor found. Gct looks for an editor to execute in the environment
229 variable GCT_EDITOR, if that variable is not set it will use the variable
230 EDITOR.''')
231 return
233 # This piece of code is not entirely satisfactory. If the user
234 # has EDITOR set to 'vi', or some other non-X application, the
235 # editor will be started in the terminal which (h)gct was
236 # started in. A better approach would be to close stdin and
237 # stdout after the fork but before the exec, but this doesn't
238 # seem to be possible with QProcess.
239 p = qt.QProcess(ed)
240 p.addArgument(it.file.dstName)
241 p.setCommunication(0)
242 qconnect(p, qt.SIGNAL('processExited()'), self.editorExited)
243 if not p.launch(qt.QByteArray()):
244 qt.QMessageBox.warning(self, 'Failed to launch editor',
245 shortName + ' failed to launch the ' + \
246 'editor. The command used was: ' + \
247 ed + ' ' + it.file.dstName)
248 else:
249 self.editorProcesses.add(p)
251 def editorExited(self):
252 p = self.sender()
253 status = p.exitStatus()
254 file = str(p.arguments()[1])
255 editor = str(p.arguments()[0]) + ' ' + file
256 if not p.normalExit():
257 qt.QMessageBox.warning(self, 'Editor failure',
258 'The editor, ' + editor + ', exited abnormally.')
259 elif status != 0:
260 qt.QMessageBox.warning(self, 'Editor failure',
261 'The editor, ' + editor + ', exited with exit code ' + str(status))
263 self.editorProcesses.remove(p)
264 scm.doUpdateCache(file)
265 self.refreshFiles()
267 def discardFile(self, ignored):
268 it = self.currentContextItem
269 if not it:
270 return
272 scm.discardFile(it.file)
273 self.refreshFiles()
275 def ignoreFile(self, ignored):
276 it = self.currentContextItem
277 if not it:
278 return
280 scm.ignoreFile(it.file)
281 self.refreshFiles()
283 def currentChange(self, item):
284 self.text.raiseWidget(item.file.textW)
285 self.text.update()
286 self.currentContextItem = item
288 def commit(self, id):
289 selFileNames = []
290 keepFiles = []
291 commitFiles = []
293 for item in self.filesW:
294 debug("file: " + item.file.text)
295 if item.isSelected():
296 selFileNames.append(item.file.text)
297 commitFiles.append(item.file.dstName)
298 else:
299 keepFiles.append(item.file)
301 commitMsg = str(self.cmitItem.file.textW.text())
303 if not selFileNames:
304 qt.QMessageBox.information(self, "Commit - " + applicationName,
305 "No files selected for commit.", "&Ok")
306 return
308 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
309 if scm.commitIsMerge():
310 mergeMsg = scm.mergeMessage()
311 else:
312 mergeMsg = ''
314 if(qt.QMessageBox.question(self, "Confirm Commit - " + applicationName,
315 '<qt><p>' + mergeMsg + '</p><p>Do you want to commit the following file(s):</p><blockquote>' +
316 '<br>'.join(selFileNames) +
317 '''</blockquote><p>with the commit message:</p><blockquote><pre>''' +
318 str(qt.QStyleSheet.escape(commitMsg)) + '</pre></blockquote></qt>',
319 '&Yes', '&No')):
320 return
321 else:
322 try:
323 scm.doCommit(keepFiles, commitFiles, commitMsg)
324 except CommitError, e:
325 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
326 "Commit failed during " + e.operation + ": " + e.msg,
327 '&Ok')
328 except OSError, e:
329 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
330 "Commit failed: " + e.strerror,
331 '&Ok')
332 else:
333 self.cmitItem.file.textW.setText(settings.signoff)
334 self.refreshFiles()
336 def getFileState(self):
337 ret = FileState()
338 cur = self.filesW.currentItem()
339 if cur and cur != self.cmitItem:
340 ret.current = self.filesW.currentItem().file.dstName
341 else:
342 ret.current = None
343 ret.selected = sets.Set()
345 for x in self.filesW:
346 if x.isSelected():
347 ret.selected.add(x.file.dstName)
349 return ret
351 def restoreFileState(self, state):
352 for f in self.files:
353 f.listViewItem.setSelected(f.dstName in state.selected)
355 for x in self.filesW:
356 if x.file.dstName == state.current:
357 self.filesW.setCurrentItem(x)
359 def newTextEdit(self):
360 ret = qt.QTextEdit()
361 self.text.addWidget(ret)
362 return ret
364 def setFiles(self, files):
365 state = self.getFileState()
366 self.filesW.clear()
367 self.createCmitItem()
368 for f in self.files:
369 self.text.removeWidget(f.textW)
370 f.listViewItem = None
372 self.files = []
373 for f in files:
374 f.textW = self.newTextEdit()
375 f.textW.setReadOnly(True)
376 f.textW.setTextFormat(Qt.RichText)
377 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
378 self.files.append(f)
380 f.listViewItem = MyListItem(self.filesW, f)
381 # Only display files that match the filter.
382 f.listViewItem.setVisible(self.filterMatch(f))
383 self.filesW.insertItem(f.listViewItem)
385 self.filesW.setCurrentItem(self.cmitItem)
387 # For some reason the currentChanged signal isn't emitted
388 # here. We call currentChange ourselves instead.
389 self.currentChange(self.cmitItem)
391 self.restoreFileState(state)
393 def refreshFiles(self, ignored=None):
394 files = scm.getFiles()
395 if settings.quitOnNoChanges and len(files) == 0:
396 self.close()
397 else:
398 self.setFiles(files)
400 return len(files) > 0
402 def filterMatch(self, file):
403 return file.dstName.find(str(self.filter.text())) != -1
405 def updateFilter(self, ignored=None):
406 for w in self.filesW:
407 w.setVisible(self.filterMatch(w.file))
409 def selectAll(self):
410 for x in self.filesW:
411 x.setSelected(True)
413 def unselectAll(self):
414 for x in self.filesW:
415 x.setSelected(False)
417 def showPrefs(self):
418 settings.showSettings()
420 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
421 def fixCommitMsgWhiteSpace(msg):
422 msg = msg.lstrip()
423 msg = msg.rstrip()
424 msg = re.sub(commitMsgRE, '\n\n', msg)
425 msg += '\n'
426 return msg
428 def formatPatchRichText(patch, colors):
429 ret = ['<qt><pre><font color="', colors['std'], '">']
430 prev = ' '
431 for l in patch.split('\n'):
432 if len(l) > 0:
433 c = l[0]
434 else:
435 c = ' '
437 if c != prev:
438 if c == '+': style = 'new'
439 elif c == '-': style = 'remove'
440 elif c == '@': style = 'head'
441 else: style = 'std'
442 ret.extend(['</font><font color="', colors[style], '">'])
443 prev = c
444 line = qt.QStyleSheet.escape(l).ascii()
445 if not line:
446 line = ''
447 else:
448 line = str(line)
449 ret.extend([line, '\n'])
450 ret.append('</pre></qt>')
451 return ''.join(ret)
453 def getEditor():
454 if os.environ.has_key('GCT_EDITOR'):
455 return os.environ['GCT_EDITOR']
456 elif os.environ.has_key('EDITOR'):
457 return os.environ['EDITOR']
458 else:
459 return None
461 scm.repoValid()
463 app = qt.QApplication(sys.argv)
465 optParser = OptionParser(usage="%prog [--gui]", version=applicationName + ' ' + version)
466 optParser.add_option('-g', '--gui', action='store_true', dest='gui',
467 help='Unconditionally start the GUI')
468 (options, args) = optParser.parse_args(app.argv()[1:])
470 settings = settings.Settings()
471 mw = MainWidget()
473 if not mw.refreshFiles() and settings.quitOnNoChanges and not options.gui:
474 print 'No outstanding changes'
475 sys.exit(0)
477 mw.resize(settings.width, settings.height)
479 # The following code doesn't work correctly in some (at least
480 # Metacity) window
481 # managers. http://doc.trolltech.com/3.3/geometry.html contains some
482 # information about this issue.
483 # mw.move(settings.readNumEntry('x', 100)[0],
484 # settings.readNumEntry('y', 100)[0])
486 mw.show()
487 app.setMainWidget(mw)
490 # Handle CTRL-C appropriately
491 signal.signal(signal.SIGINT, lambda s, f: app.quit())
493 ret = app.exec_loop()
494 settings.writeSettings()
495 sys.exit(ret)