Update email adress and git respository adress.
[hgct.git] / main.py
blob3637ebb7985d4001d92cfcca7238ff1f401a5742
1 #!/usr/bin/env python
3 # Copyright (c) 2005 Fredrik Kuivinen <frekui@gmail.com>
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 from ctcore import *
20 import sys, math, random, qt, os, re, signal, sets
21 from optparse import OptionParser
22 from commit import CommitDialog
24 # Determine semantics according to executable name. Default to git.
25 if os.path.basename(sys.argv[0]) == 'hgct':
26 import hg as scm
27 else:
28 import git as scm
30 qconnect = qt.QObject.connect
31 Qt = qt.Qt
32 #DEBUG = 1
34 class FileState:
35 pass
37 class MyListItem(qt.QCheckListItem):
38 def __init__(self, parent, file, commitMsg = False):
39 qt.QCheckListItem.__init__(self, parent, file.text, qt.QCheckListItem.CheckBox)
40 self.file = file
41 self.commitMsg = commitMsg
43 def compare(self, item, col, asc):
44 if self.commitMsg:
45 if asc:
46 return -1
47 else:
48 return 1
49 elif item.commitMsg:
50 if asc:
51 return 1
52 else:
53 return -1
54 else:
55 return cmp(self.file.srcName, item.file.srcName)
57 def paintCell(self, p, cg, col, w, a):
58 if self.commitMsg:
59 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
60 else:
61 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
63 def isSelected(self):
64 return self.state() == qt.QCheckListItem.On
66 def setSelected(self, s):
67 if s:
68 self.setState(qt.QCheckListItem.On)
69 else:
70 self.setState(qt.QCheckListItem.Off)
72 class MyListView(qt.QListView):
73 def __init__(self, parent=None, name=None):
74 qt.QListView.__init__(self, parent, name)
76 def __iter__(self):
77 return ListViewIterator(self)
79 class ListViewIterator:
80 def __init__(self, listview):
81 self.it = qt.QListViewItemIterator(listview)
83 def next(self):
84 cur = self.it.current()
85 if cur:
86 self.it += 1
87 if cur.commitMsg:
88 return self.next()
89 else:
90 return cur
91 else:
92 raise StopIteration()
94 def __iter__(self):
95 return self
97 class MainWidget(qt.QMainWindow):
98 def __init__(self, options, parent=None, name=None):
99 qt.QMainWindow.__init__(self, parent, name)
100 self.setCaption(applicationName)
101 self.statusBar()
103 splitter = qt.QSplitter(Qt.Vertical, self)
104 self.setCentralWidget(splitter)
105 self.splitter = splitter
107 # The file list and file filter widgets are part of this layout widget.
108 self.filesLayout = qt.QVBox(splitter)
110 # The file list
111 fW = MyListView(self.filesLayout)
112 self.filesW = fW
113 fW.setFocus()
114 fW.setSelectionMode(qt.QListView.NoSelection)
115 fW.addColumn('Description')
116 fW.setResizeMode(qt.QListView.AllColumns)
118 # The file filter
119 self.filterLayout = qt.QHBox(self.filesLayout)
120 self.filterClear = qt.QPushButton("&Clear", self.filterLayout)
121 self.filterLabel = qt.QLabel(" File filter: ", self.filterLayout)
122 qconnect(self.filterClear, qt.SIGNAL("clicked()"), self.clearFilter)
123 self.filter = qt.QLineEdit(self.filterLayout)
124 self.filterLabel.setBuddy(self.filter)
126 qconnect(self.filter, qt.SIGNAL("textChanged(const QString&)"), self.updateFilter)
128 self.newCurLambda = lambda i: self.currentChange(i)
129 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
131 # The diff viewing widget
132 self.text = qt.QWidgetStack(splitter)
134 ops = qt.QPopupMenu(self)
135 ops.setCheckable(True)
136 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
137 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
138 ops.insertItem("(Un)select All", self.toggleSelectAll, Qt.CTRL+Qt.Key_S)
139 self.showUnknownItem = ops.insertItem("Show Unkown Files",
140 self.toggleShowUnknown,
141 Qt.CTRL+Qt.Key_U)
142 ops.insertItem("Preferences...", self.showPrefs, Qt.CTRL+Qt.Key_P)
143 ops.setItemChecked(self.showUnknownItem, settings().showUnknown)
144 self.operations = ops
146 m = self.menuBar()
147 m.insertItem("&Operations", ops)
149 h = qt.QPopupMenu(self)
150 h.insertItem("&About", self.about)
151 m.insertItem("&Help", h)
153 qconnect(fW, qt.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
154 self.contextMenuRequestedSlot)
155 self.fileOps = qt.QPopupMenu(self)
156 self.fileOps.insertItem("Toggle selection", self.toggleFile)
157 self.fileOps.insertItem("Edit", self.editFile, Qt.CTRL+Qt.Key_E)
158 self.fileOps.insertItem("Discard changes", self.discardFile)
159 self.fileOps.insertItem("Ignore file", self.ignoreFile)
161 # The following attribute is set by contextMenuRequestedSlot
162 # and currentChange and used by the fileOps
163 self.currentContextItem = None
165 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
167 self.files = scm.fileSetFactory(lambda f: self.addFile(f),
168 lambda f: self.removeFile(f))
169 f = File()
170 f.text = "Commit message"
171 f.textW = self.newTextEdit()
172 f.textW.setTextFormat(Qt.PlainText)
173 f.textW.setReadOnly(False)
174 f.textW.setText(settings().signoff)
175 qconnect(f.textW, qt.SIGNAL('cursorPositionChanged(int, int)'),
176 self.updateCommitCursor)
177 self.cmitFile = f
178 self.createCmitItem()
179 self.editorProcesses = sets.Set()
180 self.loadSettings()
182 self.options = options
184 def updateStatusBar(self):
185 if not self.cmitFile.textW.isVisible():
186 self.setStatusBar('')
188 def setStatusBar(self, string):
189 branch = scm.getCurrentBranch()
190 if branch:
191 prefix = '[' + branch + '] '
192 else:
193 prefix = ''
194 self.statusBar().message(prefix + string)
196 def updateCommitCursor(self, *dummy):
197 [line, col] = self.cmitFile.textW.getCursorPosition()
198 self.setStatusBar('Column: ' + str(col))
200 def loadSettings(self):
201 self.splitter.setSizes(settings().splitter)
203 def closeEvent(self, e):
204 s = self.size()
205 settings().width = s.width()
206 settings().height = s.height()
207 settings().splitter = self.splitter.sizes()
208 e.accept()
210 def createCmitItem(self):
211 self.cmitItem = MyListItem(self.filesW, self.cmitFile, True)
212 self.cmitItem.setSelectable(False)
213 self.filesW.insertItem(self.cmitItem)
214 self.cmitFile.listViewItem = self.cmitItem
216 def about(self, ignore):
217 str = '<qt><center><h1>%(appName)s %(version)s</h1>' \
218 '<p>Copyright &copy; 2005 Fredrik Kuivinen &lt;frekui@gmail.com&gt;</p>' \
219 '<p>Copyright &copy; 2005 Mark Williamson &lt;maw48@cl.cam.ac.uk&gt;</p></center>' \
220 '<p>This program is free software; you can redistribute it ' \
221 'and/or modify it under the terms of the GNU General Public ' \
222 'License version 2 as published by the Free Software Foundation.' \
223 '</p></qt>' % {'appName': applicationName, 'version': version}
225 qt.QMessageBox.about(self, "About " + applicationName, str)
227 def contextMenuRequestedSlot(self, item, pos, col):
228 if item and not item.commitMsg:
229 self.currentContextItem = item
230 self.fileOps.exec_loop(qt.QCursor.pos())
231 else:
232 self.currentContextItem = None
234 def toggleFile(self, ignored):
235 it = self.currentContextItem
236 if not it:
237 return
239 if it.isSelected():
240 it.setSelected(False)
241 else:
242 it.setSelected(True)
244 def editFile(self, ignored):
245 it = self.currentContextItem
246 if not it:
247 return
249 ed = getEditor()
250 if not ed:
251 qt.QMessageBox.warning(self, 'No editor found',
252 '''No editor found. Gct looks for an editor to execute in the environment
253 variable GCT_EDITOR, if that variable is not set it will use the variable
254 EDITOR.''')
255 return
257 # This piece of code is not entirely satisfactory. If the user
258 # has EDITOR set to 'vi', or some other non-X application, the
259 # editor will be started in the terminal which (h)gct was
260 # started in. A better approach would be to close stdin and
261 # stdout after the fork but before the exec, but this doesn't
262 # seem to be possible with QProcess.
263 p = qt.QProcess(ed)
264 p.addArgument(it.file.dstName)
265 p.setCommunication(0)
266 qconnect(p, qt.SIGNAL('processExited()'), self.editorExited)
267 if not p.launch(qt.QByteArray()):
268 qt.QMessageBox.warning(self, 'Failed to launch editor',
269 shortName + ' failed to launch the ' + \
270 'editor. The command used was: ' + \
271 ed + ' ' + it.file.dstName)
272 else:
273 self.editorProcesses.add(p)
275 def editorExited(self):
276 p = self.sender()
277 status = p.exitStatus()
278 file = unicode(p.arguments()[1])
279 editor = unicode(p.arguments()[0]) + ' ' + file
280 if not p.normalExit():
281 qt.QMessageBox.warning(self, 'Editor failure',
282 'The editor, ' + editor + ', exited abnormally.')
283 elif status != 0:
284 qt.QMessageBox.warning(self, 'Editor failure',
285 'The editor, ' + editor + ', exited with exit code ' + str(status))
287 self.editorProcesses.remove(p)
288 scm.doUpdateCache(file)
289 self.refreshFiles()
291 def discardFile(self, ignored):
292 it = self.currentContextItem
293 if not it:
294 return
296 scm.discardFile(it.file)
297 self.refreshFiles()
299 def ignoreFile(self, ignored):
300 it = self.currentContextItem
301 if not it:
302 return
304 scm.ignoreFile(it.file)
305 self.refreshFiles()
307 def currentChange(self, item):
308 f = item.file
309 if not f.textW:
310 f.textW = self.newTextEdit()
311 f.textW.setReadOnly(True)
312 f.textW.setTextFormat(Qt.RichText)
313 f.textW.setText(formatPatchRichText(f.getPatch(), self.patchColors))
315 self.text.raiseWidget(f.textW)
316 self.currentContextItem = item
317 if item.commitMsg:
318 self.updateCommitCursor()
320 def commit(self, id):
321 selFileNames = []
322 keepFiles = []
323 commitFiles = []
325 for item in self.filesW:
326 debug("file: " + item.file.text)
327 if item.isSelected():
328 selFileNames.append(item.file.text)
329 commitFiles.append(item.file)
330 else:
331 keepFiles.append(item.file)
333 commitMsg = unicode(self.cmitItem.file.textW.text())
335 if not selFileNames:
336 qt.QMessageBox.information(self, "Commit - " + applicationName,
337 "No files selected for commit.", "&Ok")
338 return
340 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
341 if scm.commitIsMerge():
342 mergeMsg = scm.mergeMessage()
343 else:
344 mergeMsg = ''
346 commitDialog = CommitDialog(mergeMsg, commitMsg, selFileNames)
347 if commitDialog.exec_loop():
348 try:
349 scm.doCommit(keepFiles, commitFiles, commitMsg)
350 except ProgramError, e:
351 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
352 "Commit failed: " + str(e),
353 '&Ok')
354 else:
355 if not self.options.oneshot:
356 self.cmitItem.file.textW.setText(settings().signoff)
357 self.refreshFiles()
358 self.statusBar().message('Commit done')
360 if self.options.oneshot:
361 self.close()
363 def getFileState(self):
364 ret = FileState()
365 cur = self.filesW.currentItem()
366 if cur and cur != self.cmitItem:
367 ret.current = self.filesW.currentItem().file.text
368 else:
369 ret.current = None
370 ret.selected = sets.Set()
372 for x in self.filesW:
373 if x.isSelected():
374 ret.selected.add(x.file.text)
376 return ret
378 def restoreFileState(self, state):
379 for f in self.files:
380 f.listViewItem.setSelected(f.text in state.selected)
382 for x in self.filesW:
383 if x.file.text == state.current:
384 self.filesW.setCurrentItem(x)
386 def newTextEdit(self):
387 ret = qt.QTextEdit()
388 self.text.addWidget(ret)
389 return ret
391 def addFile(self, file):
392 f = file
393 f.listViewItem = MyListItem(self.filesW, f)
395 # The patch for this file is generated lazily in currentChange
397 # Only display files that match the filter.
398 f.listViewItem.setVisible(self.filterMatch(f))
400 self.filesW.insertItem(f.listViewItem)
403 def removeFile(self, file):
404 f = file
405 self.text.removeWidget(f.textW)
406 self.filesW.takeItem(f.listViewItem)
407 f.listViewItem = None
409 def refreshFiles(self):
410 state = self.getFileState()
412 self.setUpdatesEnabled(False)
413 scm.updateFiles(self.files)
414 self.filesW.setCurrentItem(self.cmitItem)
416 # For some reason the currentChanged signal isn't emitted
417 # here. We call currentChange ourselves instead.
418 self.currentChange(self.cmitItem)
419 self.restoreFileState(state)
420 self.setUpdatesEnabled(True)
421 self.update()
423 if settings().quitOnNoChanges and len(self.files) == 0:
424 self.close()
425 return len(self.files) > 0
427 def filterMatch(self, file):
428 return file.dstName.find(unicode(self.filter.text())) != -1
430 def updateFilter(self, ignored=None):
431 for w in self.filesW:
432 w.setVisible(self.filterMatch(w.file))
434 def clearFilter(self):
435 self.filter.setText("")
437 def toggleSelectAll(self):
438 all = False
439 for x in self.filesW:
440 if x.isVisible():
441 if not x.isSelected():
442 x.setSelected(True)
443 all = True
445 if not all:
446 for x in self.filesW:
447 if x.isVisible():
448 x.setSelected(False)
450 def toggleShowUnknown(self):
451 if settings().showUnknown:
452 settings().showUnknown = False
453 else:
454 settings().showUnknown = True
456 self.operations.setItemChecked(self.showUnknownItem, settings().showUnknown)
457 self.refreshFiles()
459 def showPrefs(self):
460 if settings().showSettings():
461 self.refreshFiles()
463 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
464 def fixCommitMsgWhiteSpace(msg):
465 msg = msg.lstrip()
466 msg = msg.rstrip()
467 msg = re.sub(commitMsgRE, '\n\n', msg)
468 msg += '\n'
469 return msg
471 def formatPatchRichText(patch, colors):
472 ret = ['<qt><pre><font color="', colors['std'], '">']
473 prev = ' '
474 for l in patch.split('\n'):
475 if len(l) > 0:
476 c = l[0]
477 else:
478 c = ' '
480 if c != prev:
481 if c == '+': style = 'new'
482 elif c == '-': style = 'remove'
483 elif c == '@': style = 'head'
484 else: style = 'std'
485 ret.extend(['</font><font color="', colors[style], '">'])
486 prev = c
487 line = unicode(qt.QStyleSheet.escape(l))
488 ret.extend([line, '\n'])
489 ret.append('</pre></qt>')
490 return u''.join(ret)
492 def getEditor():
493 if os.environ.has_key('GCT_EDITOR'):
494 return os.environ['GCT_EDITOR']
495 elif os.environ.has_key('EDITOR'):
496 return os.environ['EDITOR']
497 else:
498 return None
500 class EventFilter(qt.QObject):
501 def __init__(self, parent, mainWidget):
502 qt.QObject.__init__(self, parent)
503 self.mw = mainWidget
505 def eventFilter(self, watched, e):
506 if (e.type() == qt.QEvent.KeyRelease or \
507 e.type() == qt.QEvent.MouseButtonRelease):
508 self.mw.updateStatusBar()
510 return False
512 def main():
513 scm.initialize()
515 app = qt.QApplication(sys.argv)
517 optParser = OptionParser(usage="%prog [--gui] [--one-shot]", version=applicationName + ' ' + version)
518 optParser.add_option('-g', '--gui', action='store_true', dest='gui',
519 help='Unconditionally start the GUI')
520 optParser.add_option('-o', '--one-shot', action='store_true', dest='oneshot',
521 help="Do (at most) one commit, then exit.")
522 (options, args) = optParser.parse_args(app.argv()[1:])
524 mw = MainWidget(options)
525 ef = EventFilter(None, mw)
526 app.installEventFilter(ef)
528 if not mw.refreshFiles() and settings().quitOnNoChanges and not options.gui:
529 print 'No outstanding changes'
530 sys.exit(0)
532 mw.resize(settings().width, settings().height)
534 mw.show()
535 app.setMainWidget(mw)
537 # Handle CTRL-C appropriately
538 signal.signal(signal.SIGINT, lambda s, f: app.quit())
540 ret = app.exec_loop()
541 settings().writeSettings()
542 sys.exit(ret)
544 if executeMain:
545 main()