Add a simple statusbar.
[hgct.git] / main.py
blobf4f2e6e8f9ddc82b07df4afad5f835ff5babaf3f
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 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 CommitError(Exception):
35 def __init__(self, operation, msg):
36 self.operation = operation
37 self.msg = msg
39 class FileState:
40 pass
42 class MyListItem(qt.QCheckListItem):
43 def __init__(self, parent, file, commitMsg = False):
44 qt.QCheckListItem.__init__(self, parent, file.text, qt.QCheckListItem.CheckBox)
45 self.file = file
46 self.commitMsg = commitMsg
48 def compare(self, item, col, asc):
49 if self.commitMsg:
50 if asc:
51 return -1
52 else:
53 return 1
54 elif item.commitMsg:
55 if asc:
56 return 1
57 else:
58 return -1
59 else:
60 return cmp(self.file.srcName, item.file.srcName)
62 def paintCell(self, p, cg, col, w, a):
63 if self.commitMsg:
64 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
65 else:
66 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
68 def isSelected(self):
69 return self.state() == qt.QCheckListItem.On
71 def setSelected(self, s):
72 if s:
73 self.setState(qt.QCheckListItem.On)
74 else:
75 self.setState(qt.QCheckListItem.Off)
77 class MyListView(qt.QListView):
78 def __init__(self, parent=None, name=None):
79 qt.QListView.__init__(self, parent, name)
81 def __iter__(self):
82 return ListViewIterator(self)
84 class ListViewIterator:
85 def __init__(self, listview):
86 self.it = qt.QListViewItemIterator(listview)
88 def next(self):
89 cur = self.it.current()
90 if cur:
91 self.it += 1
92 if cur.commitMsg:
93 return self.next()
94 else:
95 return cur
96 else:
97 raise StopIteration()
99 def __iter__(self):
100 return self
102 class MainWidget(qt.QMainWindow):
103 def __init__(self, options, parent=None, name=None):
104 qt.QMainWindow.__init__(self, parent, name)
105 self.setCaption(applicationName)
106 self.statusBar()
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.filterClear = qt.QPushButton("&Clear", self.filterLayout)
126 self.filterLabel = qt.QLabel(" File filter: ", self.filterLayout)
127 qconnect(self.filterClear, qt.SIGNAL("clicked()"), self.clearFilter)
128 self.filter = qt.QLineEdit(self.filterLayout)
129 self.filterLabel.setBuddy(self.filter)
131 qconnect(self.filter, qt.SIGNAL("textChanged(const QString&)"), self.updateFilter)
133 self.newCurLambda = lambda i: self.currentChange(i)
134 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
136 # The diff viewing widget
137 self.text = qt.QWidgetStack(splitter)
139 ops = qt.QPopupMenu(self)
140 ops.setCheckable(True)
141 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
142 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
143 ops.insertItem("(Un)select All", self.toggleSelectAll, Qt.CTRL+Qt.Key_S)
144 self.showUnknownItem = ops.insertItem("Show Unkown Files",
145 self.toggleShowUnknown,
146 Qt.CTRL+Qt.Key_U)
147 ops.insertItem("Preferences...", self.showPrefs, Qt.CTRL+Qt.Key_P)
148 ops.setItemChecked(self.showUnknownItem, settings().showUnknown)
149 self.operations = ops
151 m = self.menuBar()
152 m.insertItem("&Operations", ops)
154 h = qt.QPopupMenu(self)
155 h.insertItem("&About", self.about)
156 m.insertItem("&Help", h)
158 qconnect(fW, qt.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
159 self.contextMenuRequestedSlot)
160 self.fileOps = qt.QPopupMenu(self)
161 self.fileOps.insertItem("Toggle selection", self.toggleFile)
162 self.fileOps.insertItem("Edit", self.editFile, Qt.CTRL+Qt.Key_E)
163 self.fileOps.insertItem("Discard changes", self.discardFile)
164 self.fileOps.insertItem("Ignore file", self.ignoreFile)
166 # The following attribute is set by contextMenuRequestedSlot
167 # and currentChange and used by the fileOps
168 self.currentContextItem = None
170 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
172 self.files = []
174 f = File()
175 f.text = "Commit message"
176 f.textW = self.newTextEdit()
177 f.textW.setTextFormat(Qt.PlainText)
178 f.textW.setReadOnly(False)
179 f.textW.setText(settings().signoff)
180 qconnect(f.textW, qt.SIGNAL('cursorPositionChanged(int, int)'),
181 self.updateCommitCursor)
182 self.cmitFile = f
183 self.createCmitItem()
184 self.editorProcesses = sets.Set()
185 self.loadSettings()
187 self.options = options
189 def updateStatusBar(self):
190 if not self.cmitFile.textW.isVisible():
191 self.statusBar().clear()
193 def updateCommitCursor(self, *dummy):
194 [line, col] = self.cmitFile.textW.getCursorPosition()
195 self.statusBar().message('Column: ' + str(col))
197 def loadSettings(self):
198 self.splitter.setSizes(settings().splitter)
200 def closeEvent(self, e):
201 s = self.size()
202 settings().width = s.width()
203 settings().height = s.height()
204 settings().splitter = self.splitter.sizes()
205 e.accept()
207 def createCmitItem(self):
208 self.cmitItem = MyListItem(self.filesW, self.cmitFile, True)
209 self.cmitItem.setSelectable(False)
210 self.filesW.insertItem(self.cmitItem)
211 self.cmitFile.listViewItem = self.cmitItem
213 def about(self, ignore):
214 qt.QMessageBox.about(self, "About " + applicationName,
215 "<qt><center><h1>" + applicationName + " " + version + """</h1></center>\n
216 <center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;
217 </center>\n<p>
218 <center>Copyright &copy; 2005 Mark Williamson &lt;maw48@cl.cam.ac.uk&gt;
219 </center>\n<p> This program is free software; you can redistribute it and/or
220 modify it under the terms of the GNU General Public License version 2 as
221 published by the Free Software Foundation.</p></qt>""")
223 def contextMenuRequestedSlot(self, item, pos, col):
224 if item and not item.commitMsg:
225 self.currentContextItem = item
226 self.fileOps.exec_loop(qt.QCursor.pos())
227 else:
228 self.currentContextItem = None
230 def toggleFile(self, ignored):
231 it = self.currentContextItem
232 if not it:
233 return
235 if it.isSelected():
236 it.setSelected(False)
237 else:
238 it.setSelected(True)
240 def editFile(self, ignored):
241 it = self.currentContextItem
242 if not it:
243 return
245 ed = getEditor()
246 if not ed:
247 qt.QMessageBox.warning(self, 'No editor found',
248 '''No editor found. Gct looks for an editor to execute in the environment
249 variable GCT_EDITOR, if that variable is not set it will use the variable
250 EDITOR.''')
251 return
253 # This piece of code is not entirely satisfactory. If the user
254 # has EDITOR set to 'vi', or some other non-X application, the
255 # editor will be started in the terminal which (h)gct was
256 # started in. A better approach would be to close stdin and
257 # stdout after the fork but before the exec, but this doesn't
258 # seem to be possible with QProcess.
259 p = qt.QProcess(ed)
260 p.addArgument(it.file.dstName)
261 p.setCommunication(0)
262 qconnect(p, qt.SIGNAL('processExited()'), self.editorExited)
263 if not p.launch(qt.QByteArray()):
264 qt.QMessageBox.warning(self, 'Failed to launch editor',
265 shortName + ' failed to launch the ' + \
266 'editor. The command used was: ' + \
267 ed + ' ' + it.file.dstName)
268 else:
269 self.editorProcesses.add(p)
271 def editorExited(self):
272 p = self.sender()
273 status = p.exitStatus()
274 file = str(p.arguments()[1])
275 editor = str(p.arguments()[0]) + ' ' + file
276 if not p.normalExit():
277 qt.QMessageBox.warning(self, 'Editor failure',
278 'The editor, ' + editor + ', exited abnormally.')
279 elif status != 0:
280 qt.QMessageBox.warning(self, 'Editor failure',
281 'The editor, ' + editor + ', exited with exit code ' + str(status))
283 self.editorProcesses.remove(p)
284 scm.doUpdateCache(file)
285 self.refreshFiles()
287 def discardFile(self, ignored):
288 it = self.currentContextItem
289 if not it:
290 return
292 scm.discardFile(it.file)
293 self.refreshFiles()
295 def ignoreFile(self, ignored):
296 it = self.currentContextItem
297 if not it:
298 return
300 scm.ignoreFile(it.file)
301 self.refreshFiles()
303 def currentChange(self, item):
304 self.text.raiseWidget(item.file.textW)
305 self.currentContextItem = item
306 if item.commitMsg:
307 self.updateCommitCursor()
309 def commit(self, id):
310 selFileNames = []
311 keepFiles = []
312 commitFiles = []
314 for item in self.filesW:
315 debug("file: " + item.file.text)
316 if item.isSelected():
317 selFileNames.append(item.file.text)
318 commitFiles.append(item.file)
319 else:
320 keepFiles.append(item.file)
322 commitMsg = str(self.cmitItem.file.textW.text())
324 if not selFileNames:
325 qt.QMessageBox.information(self, "Commit - " + applicationName,
326 "No files selected for commit.", "&Ok")
327 return
329 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
330 if scm.commitIsMerge():
331 mergeMsg = scm.mergeMessage()
332 else:
333 mergeMsg = ''
335 commitDialog = CommitDialog(mergeMsg, commitMsg, selFileNames)
336 if commitDialog.exec_loop():
337 try:
338 scm.doCommit(keepFiles, commitFiles, commitMsg)
339 except CommitError, e:
340 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
341 "Commit failed during " + e.operation + ": " + e.msg,
342 '&Ok')
343 except OSError, e:
344 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
345 "Commit failed: " + e.strerror,
346 '&Ok')
347 else:
348 if not self.options.oneshot:
349 self.cmitItem.file.textW.setText(settings().signoff)
350 self.refreshFiles()
351 self.statusBar().message('Commit done')
353 if self.options.oneshot:
354 self.close()
356 def getFileState(self):
357 ret = FileState()
358 cur = self.filesW.currentItem()
359 if cur and cur != self.cmitItem:
360 ret.current = self.filesW.currentItem().file.dstName
361 else:
362 ret.current = None
363 ret.selected = sets.Set()
365 for x in self.filesW:
366 if x.isSelected():
367 ret.selected.add(x.file.dstName)
369 return ret
371 def restoreFileState(self, state):
372 for f in self.files:
373 f.listViewItem.setSelected(f.dstName in state.selected)
375 for x in self.filesW:
376 if x.file.dstName == state.current:
377 self.filesW.setCurrentItem(x)
379 def newTextEdit(self):
380 ret = qt.QTextEdit()
381 self.text.addWidget(ret)
382 return ret
384 def setFiles(self, files):
385 state = self.getFileState()
386 self.filesW.clear()
387 self.createCmitItem()
388 for f in self.files:
389 self.text.removeWidget(f.textW)
390 f.listViewItem = None
392 self.files = []
393 for f in files:
394 f.textW = self.newTextEdit()
395 f.textW.setReadOnly(True)
396 f.textW.setTextFormat(Qt.RichText)
397 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
398 self.files.append(f)
400 f.listViewItem = MyListItem(self.filesW, f)
401 # Only display files that match the filter.
402 f.listViewItem.setVisible(self.filterMatch(f))
403 self.filesW.insertItem(f.listViewItem)
405 self.filesW.setCurrentItem(self.cmitItem)
407 # For some reason the currentChanged signal isn't emitted
408 # here. We call currentChange ourselves instead.
409 self.currentChange(self.cmitItem)
411 self.restoreFileState(state)
413 def refreshFiles(self, ignored=None):
414 files = scm.getFiles()
415 if settings().quitOnNoChanges and len(files) == 0:
416 self.close()
417 else:
418 self.setFiles(files)
420 return len(files) > 0
422 def filterMatch(self, file):
423 return file.dstName.find(str(self.filter.text())) != -1
425 def updateFilter(self, ignored=None):
426 for w in self.filesW:
427 w.setVisible(self.filterMatch(w.file))
429 def clearFilter(self):
430 self.filter.setText("")
432 def toggleSelectAll(self):
433 all = False
434 for x in self.filesW:
435 if x.isVisible():
436 if not x.isSelected():
437 x.setSelected(True)
438 all = True
440 if not all:
441 for x in self.filesW:
442 if x.isVisible():
443 x.setSelected(False)
445 def toggleShowUnknown(self):
446 if settings().showUnknown:
447 settings().showUnknown = False
448 else:
449 settings().showUnknown = True
451 self.operations.setItemChecked(self.showUnknownItem, settings().showUnknown)
452 self.refreshFiles()
454 def showPrefs(self):
455 if settings().showSettings():
456 self.refreshFiles()
458 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
459 def fixCommitMsgWhiteSpace(msg):
460 msg = msg.lstrip()
461 msg = msg.rstrip()
462 msg = re.sub(commitMsgRE, '\n\n', msg)
463 msg += '\n'
464 return msg
466 def formatPatchRichText(patch, colors):
467 ret = ['<qt><pre><font color="', colors['std'], '">']
468 prev = ' '
469 for l in patch.split('\n'):
470 if len(l) > 0:
471 c = l[0]
472 else:
473 c = ' '
475 if c != prev:
476 if c == '+': style = 'new'
477 elif c == '-': style = 'remove'
478 elif c == '@': style = 'head'
479 else: style = 'std'
480 ret.extend(['</font><font color="', colors[style], '">'])
481 prev = c
482 line = qt.QStyleSheet.escape(l).ascii()
483 if not line:
484 line = ''
485 else:
486 line = str(line)
487 ret.extend([line, '\n'])
488 ret.append('</pre></qt>')
489 return ''.join(ret)
491 def getEditor():
492 if os.environ.has_key('GCT_EDITOR'):
493 return os.environ['GCT_EDITOR']
494 elif os.environ.has_key('EDITOR'):
495 return os.environ['EDITOR']
496 else:
497 return None
499 class EventFilter(qt.QObject):
500 def __init__(self, parent, mainWidget):
501 qt.QObject.__init__(self, parent)
502 self.mw = mainWidget
504 def eventFilter(self, watched, e):
505 if (e.type() == qt.QEvent.KeyRelease or \
506 e.type() == qt.QEvent.MouseButtonRelease):
507 self.mw.updateStatusBar()
509 return False
511 def main():
512 scm.initialize()
514 app = qt.QApplication(sys.argv)
516 optParser = OptionParser(usage="%prog [--gui] [--one-shot]", version=applicationName + ' ' + version)
517 optParser.add_option('-g', '--gui', action='store_true', dest='gui',
518 help='Unconditionally start the GUI')
519 optParser.add_option('-o', '--one-shot', action='store_true', dest='oneshot',
520 help="Do (at most) one commit, then exit.")
521 (options, args) = optParser.parse_args(app.argv()[1:])
523 mw = MainWidget(options)
524 ef = EventFilter(None, mw)
525 app.installEventFilter(ef)
527 if not mw.refreshFiles() and settings().quitOnNoChanges and not options.gui:
528 print 'No outstanding changes'
529 sys.exit(0)
531 mw.resize(settings().width, settings().height)
533 mw.show()
534 app.setMainWidget(mw)
536 # Handle CTRL-C appropriately
537 signal.signal(signal.SIGINT, lambda s, f: app.quit())
539 ret = app.exec_loop()
540 settings().writeSettings()
541 sys.exit(ret)
543 if executeMain:
544 main()