Fix Git support for rename diffs.
[hgct.git] / main.py
blob44e1bb09dfa4a5e08fbe353e9f5aab35990ef6a7
1 #!/usr/bin/env python
3 # Copyright (c) 2005 Fredrik Kuivinen <freku045@student.liu.se>
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License version 2 as
7 # published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18 import sys, math, random, qt, os, re, signal, sets, settings
19 from optparse import OptionParser
21 from ctcore import *
23 # Determine semantics according to executable name. Default to git.
24 if os.path.basename(sys.argv[0]) == 'hgct':
25 print "defaulting to import hg because arg = %s" % sys.argv[0]
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.key(col, asc), item.key(col, asc))
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, parent=None, name=None):
104 qt.QMainWindow.__init__(self, parent, name)
105 splitter = qt.QSplitter(Qt.Vertical, self)
107 fW = MyListView(splitter)
108 fW.setFocus()
109 fW.setSelectionMode(qt.QListView.NoSelection)
110 fW.addColumn('Description')
111 fW.setResizeMode(qt.QListView.AllColumns)
113 text = qt.QWidgetStack(splitter)
115 self.setCentralWidget(splitter)
116 self.setCaption(applicationName)
118 self.newCurLambda = lambda i: self.currentChange(i)
119 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
121 ops = qt.QPopupMenu(self)
122 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
123 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
124 ops.insertItem("Select All", self.selectAll, Qt.CTRL+Qt.Key_A)
125 ops.insertItem("Unselect All", self.unselectAll, Qt.CTRL+Qt.Key_U)
126 ops.insertItem("Preferences...", self.showPrefs, Qt.CTRL+Qt.Key_P)
128 m = self.menuBar()
129 m.insertItem("&Operations", ops)
131 h = qt.QPopupMenu(self)
132 h.insertItem("&About", self.about)
133 m.insertItem("&Help", h)
135 qconnect(fW, qt.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
136 self.contextMenuRequestedSlot)
137 self.fileOps = qt.QPopupMenu(self)
138 self.fileOps.insertItem("Toggle selection", self.toggleFile)
139 self.fileOps.insertItem("Edit", self.editFile, Qt.CTRL+Qt.Key_E)
140 self.fileOps.insertItem("Discard changes", self.discardFile)
141 self.fileOps.insertItem("Ignore file", self.ignoreFile)
143 # The following attribute is set by contextMenuRequestedSlot
144 # and currentChange and used by the fileOps
145 self.currentContextItem = None
147 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
149 self.filesW = fW
150 self.files = []
151 self.splitter = splitter
152 self.text = text
154 f = File()
155 f.text = "Commit message"
156 f.textW = self.newTextEdit()
157 f.textW.setTextFormat(Qt.PlainText)
158 f.textW.setReadOnly(False)
159 f.textW.setText(settings.signoff)
161 self.cmitFile = f
162 self.createCmitItem()
163 self.editorProcesses = sets.Set()
164 self.loadSettings()
166 def loadSettings(self):
167 self.splitter.setSizes(settings.splitter)
169 def closeEvent(self, e):
170 s = self.size()
171 settings.width = s.width()
172 settings.height = s.height()
173 settings.splitter = self.splitter.sizes()
174 e.accept()
176 def createCmitItem(self):
177 self.cmitItem = MyListItem(self.filesW, self.cmitFile, True)
178 self.cmitItem.setSelectable(False)
179 self.filesW.insertItem(self.cmitItem)
181 def about(self, ignore):
182 qt.QMessageBox.about(self, "About " + applicationName,
183 "<qt><center><h1>" + applicationName + " " + version + """</h1></center>\n
184 <center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;
185 </center>\n<p>This program is free software; you can redistribute it and/or
186 modify it under the terms of the GNU General Public License version 2 as
187 published by the Free Software Foundation.</p></qt>""")
189 def contextMenuRequestedSlot(self, item, pos, col):
190 if item and not item.commitMsg:
191 self.currentContextItem = item
192 self.fileOps.exec_loop(qt.QCursor.pos())
193 else:
194 self.currentContextItem = None
196 def toggleFile(self, ignored):
197 it = self.currentContextItem
198 if not it:
199 return
201 if it.isSelected():
202 it.setSelected(False)
203 else:
204 it.setSelected(True)
206 def editFile(self, ignored):
207 it = self.currentContextItem
208 if not it:
209 return
211 ed = getEditor()
212 if not ed:
213 qt.QMessageBox.warning(self, 'No editor found',
214 '''No editor found. Gct looks for an editor to execute in the environment
215 variable GCT_EDITOR, if that variable is not set it will use the variable
216 EDITOR.''')
217 return
219 # This piece of code is not entirely satisfactory. If the user
220 # has EDITOR set to 'vi', or some other non-X application, the
221 # editor will be started in the terminal which (h)gct was
222 # started in. A better approach would be to close stdin and
223 # stdout after the fork but before the exec, but this doesn't
224 # seem to be possible with QProcess.
225 p = qt.QProcess(ed)
226 p.addArgument(it.file.dstName)
227 p.setCommunication(0)
228 qconnect(p, qt.SIGNAL('processExited()'), self.editorExited)
229 if not p.launch(qt.QByteArray()):
230 qt.QMessageBox.warning(self, 'Failed to launch editor',
231 shortName + ' failed to launch the ' + \
232 'editor. The command used was: ' + \
233 ed + ' ' + it.file.dstName)
234 else:
235 self.editorProcesses.add(p)
237 def editorExited(self):
238 p = self.sender()
239 status = p.exitStatus()
240 file = str(p.arguments()[1])
241 editor = str(p.arguments()[0]) + ' ' + file
242 if not p.normalExit():
243 qt.QMessageBox.warning(self, 'Editor failure',
244 'The editor, ' + editor + ', exited abnormally.')
245 elif status != 0:
246 qt.QMessageBox.warning(self, 'Editor failure',
247 'The editor, ' + editor + ', exited with exit code ' + str(status))
249 self.editorProcesses.remove(p)
250 scm.doUpdateCache(file)
251 self.refreshFiles()
253 def discardFile(self, ignored):
254 it = self.currentContextItem
255 if not it:
256 return
258 scm.discardFile(it.file)
259 self.refreshFiles()
261 def ignoreFile(self, ignored):
262 it = self.currentContextItem
263 if not it:
264 return
266 scm.ignoreFile(it.file)
267 self.refreshFiles()
269 def currentChange(self, item):
270 self.text.raiseWidget(item.file.textW)
271 self.text.update()
272 self.currentContextItem = item
274 def selectedItems(self):
275 ret = []
276 for item in self.filesW:
277 if item.isSelected():
278 ret.append(item)
279 return ret
281 def commit(self, id):
282 selFileNames = []
283 keepFiles = []
284 commitFiles = []
286 for item in self.filesW:
287 debug("file: " + item.file.text)
288 if item.isSelected():
289 selFileNames.append(item.file.text)
290 commitFiles.append(item.file.dstName)
291 else:
292 keepFiles.append(item.file)
294 commitMsg = str(self.cmitItem.file.textW.text())
296 if not selFileNames:
297 qt.QMessageBox.information(self, "Commit - " + applicationName,
298 "No files selected for commit.", "&Ok")
299 return
301 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
302 if(qt.QMessageBox.question(self, "Confirm Commit - " + applicationName,
303 '''<qt><p>Do you want to commit the following file(s):</p><blockquote>''' +
304 '<br>'.join(selFileNames) +
305 '''</blockquote><p>with the commit message:</p><blockquote><pre>''' +
306 str(qt.QStyleSheet.escape(commitMsg)) + '</pre></blockquote></qt>',
307 '&Yes', '&No')):
308 return
309 else:
310 try:
311 scm.doCommit(keepFiles, commitFiles, commitMsg)
312 except CommitError, e:
313 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
314 "Commit failed during " + e.operation + ": " + e.msg,
315 '&Ok')
316 except OSError, e:
317 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
318 "Commit failed: " + e.strerror,
319 '&Ok')
320 else:
321 self.cmitItem.file.textW.setText(settings.signoff)
322 self.refreshFiles()
324 def getFileState(self):
325 ret = FileState()
326 cur = self.filesW.currentItem()
327 if cur and cur != self.cmitItem:
328 ret.current = self.filesW.currentItem().file.srcName
329 else:
330 ret.current = None
331 ret.selected = {}
333 for x in self.filesW:
334 if x.isSelected():
335 ret.selected[x.file.srcName] = True
336 return ret
338 def restoreFileState(self, state):
339 for x in self.filesW:
340 if state.selected.has_key(x.file.srcName):
341 x.setSelected(True)
342 if x.file.srcName == state.current:
343 self.filesW.setCurrentItem(x)
345 def newTextEdit(self):
346 ret = qt.QTextEdit()
347 self.text.addWidget(ret)
348 return ret
350 def setFiles(self, files):
351 state = self.getFileState()
352 self.filesW.clear()
353 self.createCmitItem()
354 for f in self.files:
355 self.text.removeWidget(f.textW)
357 self.files = []
358 for f in files:
359 f.textW = self.newTextEdit()
360 f.textW.setReadOnly(False)
361 f.textW.setTextFormat(Qt.RichText)
362 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
363 self.files.append(f)
364 self.filesW.insertItem(MyListItem(self.filesW, f))
366 self.filesW.setCurrentItem(self.cmitItem)
368 # For some reason the currentChanged signal isn't emitted
369 # here. We call currentChange ourselves instead.
370 self.currentChange(self.cmitItem)
372 self.restoreFileState(state)
374 def refreshFiles(self, ignored=None):
375 files = scm.getFiles()
376 if settings.quitOnNoChanges and len(files) == 0:
377 self.close()
378 else:
379 self.setFiles(files)
381 return len(files) > 0
383 def selectAll(self):
384 for x in self.filesW:
385 x.setSelected(True)
387 def unselectAll(self):
388 for x in self.filesW:
389 x.setSelected(False)
391 def showPrefs(self):
392 settings.showSettings()
394 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
395 def fixCommitMsgWhiteSpace(msg):
396 msg = msg.lstrip()
397 msg = msg.rstrip()
398 msg = re.sub(commitMsgRE, '\n\n', msg)
399 msg += '\n'
400 return msg
402 def formatPatchRichText(patch, colors):
403 ret = ['<qt><pre><font color="', colors['std'], '">']
404 prev = ' '
405 for l in patch.split('\n'):
406 if len(l) > 0:
407 c = l[0]
408 else:
409 c = ' '
411 if c != prev:
412 if c == '+': style = 'new'
413 elif c == '-': style = 'remove'
414 elif c == '@': style = 'head'
415 else: style = 'std'
416 ret.extend(['</font><font color="', colors[style], '">'])
417 prev = c
418 line = qt.QStyleSheet.escape(l).ascii()
419 if not line:
420 line = ''
421 else:
422 line = str(line)
423 ret.extend([line, '\n'])
424 ret.append('</pre></qt>')
425 return ''.join(ret)
427 def getEditor():
428 if os.environ.has_key('GCT_EDITOR'):
429 return os.environ['GCT_EDITOR']
430 elif os.environ.has_key('EDITOR'):
431 return os.environ['EDITOR']
432 else:
433 return None
435 scm.repoValid()
437 app = qt.QApplication(sys.argv)
439 optParser = OptionParser(usage="%prog [--gui]", version=applicationName + ' ' + version)
440 optParser.add_option('-g', '--gui', action='store_true', dest='gui',
441 help='Unconditionally start the GUI')
442 (options, args) = optParser.parse_args(app.argv()[1:])
444 settings = settings.Settings()
445 mw = MainWidget()
447 if not mw.refreshFiles() and settings.quitOnNoChanges and not options.gui:
448 print 'No outstanding changes'
449 sys.exit(0)
451 mw.resize(settings.width, settings.height)
453 # The following code doesn't work correctly in some (at least
454 # Metacity) window
455 # managers. http://doc.trolltech.com/3.3/geometry.html contains some
456 # information about this issue.
457 # mw.move(settings.readNumEntry('x', 100)[0],
458 # settings.readNumEntry('y', 100)[0])
460 mw.show()
461 app.setMainWidget(mw)
464 # Handle CTRL-C appropriately
465 signal.signal(signal.SIGINT, lambda s, f: app.quit())
467 ret = app.exec_loop()
468 settings.writeSettings()
469 sys.exit(ret)