Hg: Don't generate a diff if we won't use it.
[hgct.git] / main.py
blob2cee2e0e7a97052cc90d4f84ddbd8807ffa4f202
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)
107 splitter = qt.QSplitter(Qt.Vertical, self)
108 self.setCentralWidget(splitter)
109 self.splitter = splitter
111 # The file list and file filter widgets are part of this layout widget.
112 self.filesLayout = qt.QVBox(splitter)
114 # The file list
115 fW = MyListView(self.filesLayout)
116 self.filesW = fW
117 fW.setFocus()
118 fW.setSelectionMode(qt.QListView.NoSelection)
119 fW.addColumn('Description')
120 fW.setResizeMode(qt.QListView.AllColumns)
122 # The file filter
123 self.filterLayout = qt.QHBox(self.filesLayout)
124 self.filterClear = qt.QPushButton("&Clear", self.filterLayout)
125 self.filterLabel = qt.QLabel(" File filter: ", self.filterLayout)
126 qconnect(self.filterClear, qt.SIGNAL("clicked()"), self.clearFilter)
127 self.filter = qt.QLineEdit(self.filterLayout)
128 self.filterLabel.setBuddy(self.filter)
130 qconnect(self.filter, qt.SIGNAL("textChanged(const QString&)"), self.updateFilter)
132 self.newCurLambda = lambda i: self.currentChange(i)
133 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
135 # The diff viewing widget
136 self.text = qt.QWidgetStack(splitter)
138 ops = qt.QPopupMenu(self)
139 ops.setCheckable(True)
140 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
141 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
142 ops.insertItem("(Un)select All", self.toggleSelectAll, Qt.CTRL+Qt.Key_S)
143 self.showUnknownItem = ops.insertItem("Show Unkown Files",
144 self.toggleShowUnknown,
145 Qt.CTRL+Qt.Key_U)
146 ops.insertItem("Preferences...", self.showPrefs, Qt.CTRL+Qt.Key_P)
147 ops.setItemChecked(self.showUnknownItem, settings().showUnknown)
148 self.operations = ops
150 m = self.menuBar()
151 m.insertItem("&Operations", ops)
153 h = qt.QPopupMenu(self)
154 h.insertItem("&About", self.about)
155 m.insertItem("&Help", h)
157 qconnect(fW, qt.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
158 self.contextMenuRequestedSlot)
159 self.fileOps = qt.QPopupMenu(self)
160 self.fileOps.insertItem("Toggle selection", self.toggleFile)
161 self.fileOps.insertItem("Edit", self.editFile, Qt.CTRL+Qt.Key_E)
162 self.fileOps.insertItem("Discard changes", self.discardFile)
163 self.fileOps.insertItem("Ignore file", self.ignoreFile)
165 # The following attribute is set by contextMenuRequestedSlot
166 # and currentChange and used by the fileOps
167 self.currentContextItem = None
169 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
171 self.files = []
173 f = File()
174 f.text = "Commit message"
175 f.textW = self.newTextEdit()
176 f.textW.setTextFormat(Qt.PlainText)
177 f.textW.setReadOnly(False)
178 f.textW.setText(settings().signoff)
180 self.cmitFile = f
181 self.createCmitItem()
182 self.editorProcesses = sets.Set()
183 self.loadSettings()
185 self.options = options
187 def loadSettings(self):
188 self.splitter.setSizes(settings().splitter)
190 def closeEvent(self, e):
191 s = self.size()
192 settings().width = s.width()
193 settings().height = s.height()
194 settings().splitter = self.splitter.sizes()
195 e.accept()
197 def createCmitItem(self):
198 self.cmitItem = MyListItem(self.filesW, self.cmitFile, True)
199 self.cmitItem.setSelectable(False)
200 self.filesW.insertItem(self.cmitItem)
201 self.cmitFile.listViewItem = self.cmitItem
203 def about(self, ignore):
204 qt.QMessageBox.about(self, "About " + applicationName,
205 "<qt><center><h1>" + applicationName + " " + version + """</h1></center>\n
206 <center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;
207 </center>\n<p>
208 <center>Copyright &copy; 2005 Mark Williamson &lt;maw48@cl.cam.ac.uk&gt;
209 </center>\n<p> This program is free software; you can redistribute it and/or
210 modify it under the terms of the GNU General Public License version 2 as
211 published by the Free Software Foundation.</p></qt>""")
213 def contextMenuRequestedSlot(self, item, pos, col):
214 if item and not item.commitMsg:
215 self.currentContextItem = item
216 self.fileOps.exec_loop(qt.QCursor.pos())
217 else:
218 self.currentContextItem = None
220 def toggleFile(self, ignored):
221 it = self.currentContextItem
222 if not it:
223 return
225 if it.isSelected():
226 it.setSelected(False)
227 else:
228 it.setSelected(True)
230 def editFile(self, ignored):
231 it = self.currentContextItem
232 if not it:
233 return
235 ed = getEditor()
236 if not ed:
237 qt.QMessageBox.warning(self, 'No editor found',
238 '''No editor found. Gct looks for an editor to execute in the environment
239 variable GCT_EDITOR, if that variable is not set it will use the variable
240 EDITOR.''')
241 return
243 # This piece of code is not entirely satisfactory. If the user
244 # has EDITOR set to 'vi', or some other non-X application, the
245 # editor will be started in the terminal which (h)gct was
246 # started in. A better approach would be to close stdin and
247 # stdout after the fork but before the exec, but this doesn't
248 # seem to be possible with QProcess.
249 p = qt.QProcess(ed)
250 p.addArgument(it.file.dstName)
251 p.setCommunication(0)
252 qconnect(p, qt.SIGNAL('processExited()'), self.editorExited)
253 if not p.launch(qt.QByteArray()):
254 qt.QMessageBox.warning(self, 'Failed to launch editor',
255 shortName + ' failed to launch the ' + \
256 'editor. The command used was: ' + \
257 ed + ' ' + it.file.dstName)
258 else:
259 self.editorProcesses.add(p)
261 def editorExited(self):
262 p = self.sender()
263 status = p.exitStatus()
264 file = str(p.arguments()[1])
265 editor = str(p.arguments()[0]) + ' ' + file
266 if not p.normalExit():
267 qt.QMessageBox.warning(self, 'Editor failure',
268 'The editor, ' + editor + ', exited abnormally.')
269 elif status != 0:
270 qt.QMessageBox.warning(self, 'Editor failure',
271 'The editor, ' + editor + ', exited with exit code ' + str(status))
273 self.editorProcesses.remove(p)
274 scm.doUpdateCache(file)
275 self.refreshFiles()
277 def discardFile(self, ignored):
278 it = self.currentContextItem
279 if not it:
280 return
282 scm.discardFile(it.file)
283 self.refreshFiles()
285 def ignoreFile(self, ignored):
286 it = self.currentContextItem
287 if not it:
288 return
290 scm.ignoreFile(it.file)
291 self.refreshFiles()
293 def currentChange(self, item):
294 self.text.raiseWidget(item.file.textW)
295 self.text.update()
296 self.currentContextItem = item
298 def commit(self, id):
299 selFileNames = []
300 keepFiles = []
301 commitFiles = []
303 for item in self.filesW:
304 debug("file: " + item.file.text)
305 if item.isSelected():
306 selFileNames.append(item.file.text)
307 commitFiles.append(item.file)
308 else:
309 keepFiles.append(item.file)
311 commitMsg = str(self.cmitItem.file.textW.text())
313 if not selFileNames:
314 qt.QMessageBox.information(self, "Commit - " + applicationName,
315 "No files selected for commit.", "&Ok")
316 return
318 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
319 if scm.commitIsMerge():
320 mergeMsg = scm.mergeMessage()
321 else:
322 mergeMsg = ''
324 commitDialog = CommitDialog(mergeMsg, commitMsg, selFileNames)
325 if commitDialog.exec_loop():
326 try:
327 scm.doCommit(keepFiles, commitFiles, commitMsg)
328 except CommitError, e:
329 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
330 "Commit failed during " + e.operation + ": " + e.msg,
331 '&Ok')
332 except OSError, e:
333 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
334 "Commit failed: " + e.strerror,
335 '&Ok')
336 else:
337 if not self.options.oneshot:
338 self.cmitItem.file.textW.setText(settings().signoff)
339 self.refreshFiles()
341 if self.options.oneshot:
342 self.close()
344 def getFileState(self):
345 ret = FileState()
346 cur = self.filesW.currentItem()
347 if cur and cur != self.cmitItem:
348 ret.current = self.filesW.currentItem().file.dstName
349 else:
350 ret.current = None
351 ret.selected = sets.Set()
353 for x in self.filesW:
354 if x.isSelected():
355 ret.selected.add(x.file.dstName)
357 return ret
359 def restoreFileState(self, state):
360 for f in self.files:
361 f.listViewItem.setSelected(f.dstName in state.selected)
363 for x in self.filesW:
364 if x.file.dstName == state.current:
365 self.filesW.setCurrentItem(x)
367 def newTextEdit(self):
368 ret = qt.QTextEdit()
369 self.text.addWidget(ret)
370 return ret
372 def setFiles(self, files):
373 state = self.getFileState()
374 self.filesW.clear()
375 self.createCmitItem()
376 for f in self.files:
377 self.text.removeWidget(f.textW)
378 f.listViewItem = None
380 self.files = []
381 for f in files:
382 f.textW = self.newTextEdit()
383 f.textW.setReadOnly(True)
384 f.textW.setTextFormat(Qt.RichText)
385 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
386 self.files.append(f)
388 f.listViewItem = MyListItem(self.filesW, f)
389 # Only display files that match the filter.
390 f.listViewItem.setVisible(self.filterMatch(f))
391 self.filesW.insertItem(f.listViewItem)
393 self.filesW.setCurrentItem(self.cmitItem)
395 # For some reason the currentChanged signal isn't emitted
396 # here. We call currentChange ourselves instead.
397 self.currentChange(self.cmitItem)
399 self.restoreFileState(state)
401 def refreshFiles(self, ignored=None):
402 files = scm.getFiles()
403 if settings().quitOnNoChanges and len(files) == 0:
404 self.close()
405 else:
406 self.setFiles(files)
408 return len(files) > 0
410 def filterMatch(self, file):
411 return file.dstName.find(str(self.filter.text())) != -1
413 def updateFilter(self, ignored=None):
414 for w in self.filesW:
415 w.setVisible(self.filterMatch(w.file))
417 def clearFilter(self):
418 self.filter.setText("")
420 def toggleSelectAll(self):
421 all = False
422 for x in self.filesW:
423 if x.isVisible():
424 if not x.isSelected():
425 x.setSelected(True)
426 all = True
428 if not all:
429 for x in self.filesW:
430 if x.isVisible():
431 x.setSelected(False)
433 def toggleShowUnknown(self):
434 if settings().showUnknown:
435 settings().showUnknown = False
436 else:
437 settings().showUnknown = True
439 self.operations.setItemChecked(self.showUnknownItem, settings().showUnknown)
440 self.refreshFiles()
442 def showPrefs(self):
443 if settings().showSettings():
444 self.refreshFiles()
446 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
447 def fixCommitMsgWhiteSpace(msg):
448 msg = msg.lstrip()
449 msg = msg.rstrip()
450 msg = re.sub(commitMsgRE, '\n\n', msg)
451 msg += '\n'
452 return msg
454 def formatPatchRichText(patch, colors):
455 ret = ['<qt><pre><font color="', colors['std'], '">']
456 prev = ' '
457 for l in patch.split('\n'):
458 if len(l) > 0:
459 c = l[0]
460 else:
461 c = ' '
463 if c != prev:
464 if c == '+': style = 'new'
465 elif c == '-': style = 'remove'
466 elif c == '@': style = 'head'
467 else: style = 'std'
468 ret.extend(['</font><font color="', colors[style], '">'])
469 prev = c
470 line = qt.QStyleSheet.escape(l).ascii()
471 if not line:
472 line = ''
473 else:
474 line = str(line)
475 ret.extend([line, '\n'])
476 ret.append('</pre></qt>')
477 return ''.join(ret)
479 def getEditor():
480 if os.environ.has_key('GCT_EDITOR'):
481 return os.environ['GCT_EDITOR']
482 elif os.environ.has_key('EDITOR'):
483 return os.environ['EDITOR']
484 else:
485 return None
487 def main():
488 scm.initialize()
490 app = qt.QApplication(sys.argv)
492 optParser = OptionParser(usage="%prog [--gui] [--one-shot]", version=applicationName + ' ' + version)
493 optParser.add_option('-g', '--gui', action='store_true', dest='gui',
494 help='Unconditionally start the GUI')
495 optParser.add_option('-o', '--one-shot', action='store_true', dest='oneshot',
496 help="Do (at most) one commit, then exit.")
497 (options, args) = optParser.parse_args(app.argv()[1:])
499 mw = MainWidget(options)
501 if not mw.refreshFiles() and settings().quitOnNoChanges and not options.gui:
502 print 'No outstanding changes'
503 sys.exit(0)
505 mw.resize(settings().width, settings().height)
507 mw.show()
508 app.setMainWidget(mw)
511 # Handle CTRL-C appropriately
512 signal.signal(signal.SIGINT, lambda s, f: app.quit())
514 ret = app.exec_loop()
515 settings().writeSettings()
516 sys.exit(ret)
518 if executeMain:
519 main()