Add an option to tune the auto adding file behaviour.
[hgct.git] / main.py
blob7d109dbc23348760878c2704c41956040df32adc
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
23 # Determine semantics according to executable name. Default to git.
24 if os.path.basename(sys.argv[0]) == 'hgct':
25 import hg as scm
26 else:
27 import git as scm
29 qconnect = qt.QObject.connect
30 Qt = qt.Qt
31 #DEBUG = 1
33 class CommitError(Exception):
34 def __init__(self, operation, msg):
35 self.operation = operation
36 self.msg = msg
38 class FileState:
39 pass
41 class MyListItem(qt.QCheckListItem):
42 def __init__(self, parent, file, commitMsg = False):
43 qt.QCheckListItem.__init__(self, parent, file.text, qt.QCheckListItem.CheckBox)
44 self.file = file
45 self.commitMsg = commitMsg
47 def compare(self, item, col, asc):
48 if self.commitMsg:
49 if asc:
50 return -1
51 else:
52 return 1
53 elif item.commitMsg:
54 if asc:
55 return 1
56 else:
57 return -1
58 else:
59 return cmp(self.file.srcName, item.file.srcName)
61 def paintCell(self, p, cg, col, w, a):
62 if self.commitMsg:
63 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
64 else:
65 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
67 def isSelected(self):
68 return self.state() == qt.QCheckListItem.On
70 def setSelected(self, s):
71 if s:
72 self.setState(qt.QCheckListItem.On)
73 else:
74 self.setState(qt.QCheckListItem.Off)
76 class MyListView(qt.QListView):
77 def __init__(self, parent=None, name=None):
78 qt.QListView.__init__(self, parent, name)
80 def __iter__(self):
81 return ListViewIterator(self)
83 class ListViewIterator:
84 def __init__(self, listview):
85 self.it = qt.QListViewItemIterator(listview)
87 def next(self):
88 cur = self.it.current()
89 if cur:
90 self.it += 1
91 if cur.commitMsg:
92 return self.next()
93 else:
94 return cur
95 else:
96 raise StopIteration()
98 def __iter__(self):
99 return self
101 class MainWidget(qt.QMainWindow):
102 def __init__(self, options, parent=None, name=None):
103 qt.QMainWindow.__init__(self, parent, name)
104 self.setCaption(applicationName)
106 splitter = qt.QSplitter(Qt.Vertical, self)
107 self.setCentralWidget(splitter)
108 self.splitter = splitter
110 # The file list and file filter widgets are part of this layout widget.
111 self.filesLayout = qt.QVBox(splitter)
113 # The file list
114 fW = MyListView(self.filesLayout)
115 self.filesW = fW
116 fW.setFocus()
117 fW.setSelectionMode(qt.QListView.NoSelection)
118 fW.addColumn('Description')
119 fW.setResizeMode(qt.QListView.AllColumns)
121 # The file filter
122 self.filterLayout = qt.QHBox(self.filesLayout)
123 self.filterClear = qt.QPushButton("&Clear", self.filterLayout)
124 self.filterLabel = qt.QLabel(" File filter: ", self.filterLayout)
125 qconnect(self.filterClear, qt.SIGNAL("clicked()"), self.clearFilter)
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 self.options = options
181 def loadSettings(self):
182 self.splitter.setSizes(settings().splitter)
184 def closeEvent(self, e):
185 s = self.size()
186 settings().width = s.width()
187 settings().height = s.height()
188 settings().splitter = self.splitter.sizes()
189 e.accept()
191 def createCmitItem(self):
192 self.cmitItem = MyListItem(self.filesW, self.cmitFile, True)
193 self.cmitItem.setSelectable(False)
194 self.filesW.insertItem(self.cmitItem)
195 self.cmitFile.listViewItem = self.cmitItem
197 def about(self, ignore):
198 qt.QMessageBox.about(self, "About " + applicationName,
199 "<qt><center><h1>" + applicationName + " " + version + """</h1></center>\n
200 <center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;
201 </center>\n<p>
202 <center>Copyright &copy; 2005 Mark Williamson &lt;maw48@cl.cam.ac.uk&gt;
203 </center>\n<p> This program is free software; you can redistribute it and/or
204 modify it under the terms of the GNU General Public License version 2 as
205 published by the Free Software Foundation.</p></qt>""")
207 def contextMenuRequestedSlot(self, item, pos, col):
208 if item and not item.commitMsg:
209 self.currentContextItem = item
210 self.fileOps.exec_loop(qt.QCursor.pos())
211 else:
212 self.currentContextItem = None
214 def toggleFile(self, ignored):
215 it = self.currentContextItem
216 if not it:
217 return
219 if it.isSelected():
220 it.setSelected(False)
221 else:
222 it.setSelected(True)
224 def editFile(self, ignored):
225 it = self.currentContextItem
226 if not it:
227 return
229 ed = getEditor()
230 if not ed:
231 qt.QMessageBox.warning(self, 'No editor found',
232 '''No editor found. Gct looks for an editor to execute in the environment
233 variable GCT_EDITOR, if that variable is not set it will use the variable
234 EDITOR.''')
235 return
237 # This piece of code is not entirely satisfactory. If the user
238 # has EDITOR set to 'vi', or some other non-X application, the
239 # editor will be started in the terminal which (h)gct was
240 # started in. A better approach would be to close stdin and
241 # stdout after the fork but before the exec, but this doesn't
242 # seem to be possible with QProcess.
243 p = qt.QProcess(ed)
244 p.addArgument(it.file.dstName)
245 p.setCommunication(0)
246 qconnect(p, qt.SIGNAL('processExited()'), self.editorExited)
247 if not p.launch(qt.QByteArray()):
248 qt.QMessageBox.warning(self, 'Failed to launch editor',
249 shortName + ' failed to launch the ' + \
250 'editor. The command used was: ' + \
251 ed + ' ' + it.file.dstName)
252 else:
253 self.editorProcesses.add(p)
255 def editorExited(self):
256 p = self.sender()
257 status = p.exitStatus()
258 file = str(p.arguments()[1])
259 editor = str(p.arguments()[0]) + ' ' + file
260 if not p.normalExit():
261 qt.QMessageBox.warning(self, 'Editor failure',
262 'The editor, ' + editor + ', exited abnormally.')
263 elif status != 0:
264 qt.QMessageBox.warning(self, 'Editor failure',
265 'The editor, ' + editor + ', exited with exit code ' + str(status))
267 self.editorProcesses.remove(p)
268 scm.doUpdateCache(file)
269 self.refreshFiles()
271 def discardFile(self, ignored):
272 it = self.currentContextItem
273 if not it:
274 return
276 scm.discardFile(it.file)
277 self.refreshFiles()
279 def ignoreFile(self, ignored):
280 it = self.currentContextItem
281 if not it:
282 return
284 scm.ignoreFile(it.file)
285 self.refreshFiles()
287 def currentChange(self, item):
288 self.text.raiseWidget(item.file.textW)
289 self.text.update()
290 self.currentContextItem = item
292 def commit(self, id):
293 selFileNames = []
294 keepFiles = []
295 commitFiles = []
297 for item in self.filesW:
298 debug("file: " + item.file.text)
299 if item.isSelected():
300 selFileNames.append(item.file.text)
301 commitFiles.append(item.file)
302 else:
303 keepFiles.append(item.file)
305 commitMsg = str(self.cmitItem.file.textW.text())
307 if not selFileNames:
308 qt.QMessageBox.information(self, "Commit - " + applicationName,
309 "No files selected for commit.", "&Ok")
310 return
312 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
313 if scm.commitIsMerge():
314 mergeMsg = scm.mergeMessage()
315 else:
316 mergeMsg = ''
318 if(qt.QMessageBox.question(self, "Confirm Commit - " + applicationName,
319 '<qt><p>' + mergeMsg + '</p><p>Do you want to commit the following file(s):</p><blockquote>' +
320 '<br>'.join(selFileNames) +
321 '''</blockquote><p>with the commit message:</p><blockquote><pre>''' +
322 str(qt.QStyleSheet.escape(commitMsg)) + '</pre></blockquote></qt>',
323 '&Yes', '&No')):
324 return
325 else:
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()
345 def getFileState(self):
346 ret = FileState()
347 cur = self.filesW.currentItem()
348 if cur and cur != self.cmitItem:
349 ret.current = self.filesW.currentItem().file.dstName
350 else:
351 ret.current = None
352 ret.selected = sets.Set()
354 for x in self.filesW:
355 if x.isSelected():
356 ret.selected.add(x.file.dstName)
358 return ret
360 def restoreFileState(self, state):
361 for f in self.files:
362 f.listViewItem.setSelected(f.dstName in state.selected)
364 for x in self.filesW:
365 if x.file.dstName == state.current:
366 self.filesW.setCurrentItem(x)
368 def newTextEdit(self):
369 ret = qt.QTextEdit()
370 self.text.addWidget(ret)
371 return ret
373 def setFiles(self, files):
374 state = self.getFileState()
375 self.filesW.clear()
376 self.createCmitItem()
377 for f in self.files:
378 self.text.removeWidget(f.textW)
379 f.listViewItem = None
381 self.files = []
382 for f in files:
383 f.textW = self.newTextEdit()
384 f.textW.setReadOnly(True)
385 f.textW.setTextFormat(Qt.RichText)
386 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
387 self.files.append(f)
389 f.listViewItem = MyListItem(self.filesW, f)
390 # Only display files that match the filter.
391 f.listViewItem.setVisible(self.filterMatch(f))
392 self.filesW.insertItem(f.listViewItem)
394 self.filesW.setCurrentItem(self.cmitItem)
396 # For some reason the currentChanged signal isn't emitted
397 # here. We call currentChange ourselves instead.
398 self.currentChange(self.cmitItem)
400 self.restoreFileState(state)
402 def refreshFiles(self, ignored=None):
403 files = scm.getFiles()
404 if settings().quitOnNoChanges and len(files) == 0:
405 self.close()
406 else:
407 self.setFiles(files)
409 return len(files) > 0
411 def filterMatch(self, file):
412 return file.dstName.find(str(self.filter.text())) != -1
414 def updateFilter(self, ignored=None):
415 for w in self.filesW:
416 w.setVisible(self.filterMatch(w.file))
418 def clearFilter(self):
419 self.filter.setText("")
421 def selectAll(self):
422 for x in self.filesW:
423 if x.isVisible():
424 x.setSelected(True)
426 def unselectAll(self):
427 for x in self.filesW:
428 if x.isVisible():
429 x.setSelected(False)
431 def showPrefs(self):
432 if settings().showSettings():
433 self.refreshFiles()
435 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
436 def fixCommitMsgWhiteSpace(msg):
437 msg = msg.lstrip()
438 msg = msg.rstrip()
439 msg = re.sub(commitMsgRE, '\n\n', msg)
440 msg += '\n'
441 return msg
443 def formatPatchRichText(patch, colors):
444 ret = ['<qt><pre><font color="', colors['std'], '">']
445 prev = ' '
446 for l in patch.split('\n'):
447 if len(l) > 0:
448 c = l[0]
449 else:
450 c = ' '
452 if c != prev:
453 if c == '+': style = 'new'
454 elif c == '-': style = 'remove'
455 elif c == '@': style = 'head'
456 else: style = 'std'
457 ret.extend(['</font><font color="', colors[style], '">'])
458 prev = c
459 line = qt.QStyleSheet.escape(l).ascii()
460 if not line:
461 line = ''
462 else:
463 line = str(line)
464 ret.extend([line, '\n'])
465 ret.append('</pre></qt>')
466 return ''.join(ret)
468 def getEditor():
469 if os.environ.has_key('GCT_EDITOR'):
470 return os.environ['GCT_EDITOR']
471 elif os.environ.has_key('EDITOR'):
472 return os.environ['EDITOR']
473 else:
474 return None
476 scm.repoValid()
478 app = qt.QApplication(sys.argv)
480 optParser = OptionParser(usage="%prog [--gui] [--one-shot]", version=applicationName + ' ' + version)
481 optParser.add_option('-g', '--gui', action='store_true', dest='gui',
482 help='Unconditionally start the GUI')
483 optParser.add_option('-o', '--one-shot', action='store_true', dest='oneshot',
484 help="Do (at most) one commit, then exit.")
485 (options, args) = optParser.parse_args(app.argv()[1:])
487 mw = MainWidget(options)
489 if not mw.refreshFiles() and settings().quitOnNoChanges and not options.gui:
490 print 'No outstanding changes'
491 sys.exit(0)
493 mw.resize(settings().width, settings().height)
495 # The following code doesn't work correctly in some (at least
496 # Metacity) window
497 # managers. http://doc.trolltech.com/3.3/geometry.html contains some
498 # information about this issue.
499 # mw.move(settings.readNumEntry('x', 100)[0],
500 # settings.readNumEntry('y', 100)[0])
502 mw.show()
503 app.setMainWidget(mw)
506 # Handle CTRL-C appropriately
507 signal.signal(signal.SIGINT, lambda s, f: app.quit())
509 ret = app.exec_loop()
510 settings().writeSettings()
511 sys.exit(ret)