Merge Hgct
[hgct.git] / main.py
blobc110eaccdd0aab1d6921dab6e2278f183b0e542f
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
20 from ctcore import *
22 # Determine semantics according to executable name. Default to git.
23 if re.split('/', sys.argv[0]).pop() == 'hgct':
24 import hg as scm
25 else:
26 print "defaulting to import git because arg = %s" % sys.argv[0]
27 import git as scm
29 qconnect = qt.QObject.connect
30 Qt = qt.Qt
31 applicationName = 'Commit Tool'
32 shortName = 'ct'
33 version = 'v0.1'
34 #DEBUG = 1
36 class CommitError(Exception):
37 def __init__(self, operation, msg):
38 self.operation = operation
39 self.msg = msg
41 class FileState:
42 pass
44 class MyListItem(qt.QCheckListItem):
45 def __init__(self, parent, file, inSync, commitMsg = False):
46 if inSync:
47 status = 'In sync '
48 else:
49 status = 'Working directory out of sync '
50 if commitMsg:
51 status = ''
53 qt.QListViewItem.__init__(self, parent, file.text, qt.QCheckListItem.CheckBox)
54 self.setText(1, status)
55 self.inSync = inSync
56 self.file = file
57 self.commitMsg = commitMsg
59 def compare(self, item, col, asc):
60 if self.commitMsg:
61 if asc:
62 return -1
63 else:
64 return 1
65 elif item.commitMsg:
66 if asc:
67 return 1
68 else:
69 return -1
70 else:
71 return cmp(self.key(col, asc), item.key(col, asc))
73 def paintCell(self, p, cg, col, w, a):
74 if col == 1 and not self.inSync:
75 cg.setColor(qt.QColorGroup.Text, Qt.red)
76 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
77 cg.setColor(qt.QColorGroup.Text, Qt.black)
78 else:
79 if self.commitMsg:
80 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
81 else:
82 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
84 def isSelected(self):
85 return self.state() == qt.QCheckListItem.On
87 def setSelected(self, s):
88 if s:
89 self.setState(qt.QCheckListItem.On)
90 else:
91 self.setState(qt.QCheckListItem.Off)
93 class MyListView(qt.QListView):
94 def __init__(self, parent=None, name=None):
95 qt.QListView.__init__(self, parent, name)
97 def __iter__(self):
98 return ListViewIterator(self)
100 class ListViewIterator:
101 def __init__(self, listview):
102 self.it = qt.QListViewItemIterator(listview)
104 def next(self):
105 cur = self.it.current()
106 if cur:
107 self.it += 1
108 if cur.commitMsg:
109 return self.next()
110 else:
111 return cur
112 else:
113 raise StopIteration()
115 def __iter__(self):
116 return self
118 class MainWidget(qt.QMainWindow):
119 def __init__(self, parent=None, name=None):
120 qt.QMainWindow.__init__(self, parent, name)
121 splitter = qt.QSplitter(Qt.Vertical, self)
123 fW = MyListView(splitter)
124 fW.setFocus()
125 fW.setSelectionMode(qt.QListView.NoSelection)
126 fW.addColumn('Description')
127 statusTitle = 'Cache Status'
128 fW.addColumn(statusTitle)
129 fW.setResizeMode(qt.QListView.AllColumns)
130 fW.header().setStretchEnabled(1, False)
131 fW.setColumnWidth(1, self.fontMetrics().width(statusTitle + 'xxx'))
133 text = qt.QWidgetStack(splitter)
135 self.setCentralWidget(splitter)
136 self.setCaption(applicationName)
138 self.newCurLambda = lambda i: self.currentChange(i)
139 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
141 ops = qt.QPopupMenu(self)
142 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
143 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
144 ops.insertItem("Update Cache for Selected Files", self.updateCacheSelected, Qt.CTRL+Qt.Key_U)
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("(Un)select", self.toggleFile)
157 self.fileOps.insertItem("Edit", self.editFile, Qt.CTRL+Qt.Key_E)
159 # The following attribute is set by contextMenuRequestedSlot and used
160 # by the fileOps
161 self.currentContextItem = None
163 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
165 self.filesW = fW
166 self.files = []
167 self.splitter = splitter
168 self.text = text
170 f = File()
171 f.text = "Commit message"
172 f.textW = self.newTextEdit()
173 f.textW.setTextFormat(Qt.PlainText)
174 f.textW.setReadOnly(False)
175 self.cmitFile = f
176 self.createCmitItem()
177 self.splitter.setSizes(eval(str(settings.readEntry('splitter', '[400, 200]')[0])))
179 def closeEvent(self, e):
180 p = self.pos()
181 settings.writeEntry('x', p.x()),
182 settings.writeEntry('y', p.y()),
184 s = self.size()
185 settings.writeEntry('width', s.width()),
186 settings.writeEntry('height', s.height())
188 settings.writeEntry('splitter', str(self.splitter.sizes()))
189 e.accept()
191 def createCmitItem(self):
192 self.cmitItem = MyListItem(self.filesW, self.cmitFile, False, True)
193 self.cmitItem.setSelectable(False)
194 self.filesW.insertItem(self.cmitItem)
196 def about(self, ignore):
197 qt.QMessageBox.about(self, "About " + applicationName,
198 "<qt><center><h1>" + applicationName + " " + version + """</h1></center>\n
199 <center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;
200 </center>\n<p>This program is free software; you can redistribute it and/or
201 modify it under the terms of the GNU General Public License version 2 as
202 published by the Free Software Foundation.</p></qt>""")
204 def contextMenuRequestedSlot(self, item, pos, col):
205 if item and not item.commitMsg:
206 self.currentContextItem = item
207 self.fileOps.exec_loop(qt.QCursor.pos())
208 else:
209 self.currentContextItem = None
211 def toggleFile(self, ignored):
212 it = self.currentContextItem
213 if not it:
214 return
216 if it.isSelected():
217 it.setSelected(False)
218 else:
219 it.setSelected(True)
221 def editFile(self, ignored):
222 it = self.currentContextItem
223 if not it:
224 return
226 ed = getEditor()
227 if not ed:
228 qt.QMessageBox.warning(self, 'No editor found',
229 '''No editor found. Gct looks for an editor to execute in the environment
230 variable GCT_EDITOR, if that variable is not set it will use the variable
231 EDITOR.''')
232 return
233 # We can't use runProgram([ed, it.file.dstName]) here because
234 # ed might be something like 'xemacs -nw' which has to be
235 # interpreted by the shell.
236 try:
237 runProgram(ed + ' ' + shellQuote(it.file.dstName))
238 doUpdateCache(it.file.dstName)
239 self.refreshFiles()
240 except ProgramError, e:
241 qt.QMessageBox.warning(self, 'Failed to launch editor',
242 '''Gct failed to launch the editor. The command used was: ''' + e.program)
244 def currentChange(self, item):
245 self.text.raiseWidget(item.file.textW)
246 self.text.update()
248 def updateCacheSelected(self, id):
249 for it in self.selectedItems():
250 scm.doUpdateCache(it.file.dstName)
251 self.refreshFiles()
253 def selectedItems(self):
254 ret = []
255 for item in self.filesW:
256 if item.isSelected():
257 ret.append(item)
258 return ret
260 def commit(self, id):
261 selFileNames = []
262 keepFiles = []
263 commitFiles = []
265 for item in self.filesW:
266 debug("file: " + item.file.text)
267 if item.isSelected():
268 selFileNames.append(item.file.text)
269 commitFiles.append(item.file.dstName)
270 else:
271 keepFiles.append(item.file)
273 commitMsg = str(self.cmitItem.file.textW.text())
275 if not selFileNames:
276 qt.QMessageBox.information(self, "Commit - " + applicationName,
277 "No files selected for commit.", "&Ok")
278 return
280 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
281 if(qt.QMessageBox.question(self, "Confirm Commit - " + applicationName,
282 '''<qt><p>Do you want to commit the following file(s):</p><blockquote>''' +
283 '<br>'.join(selFileNames) +
284 '''</blockquote><p>with the commit message:</p><blockquote><pre>''' +
285 str(qt.QStyleSheet.escape(commitMsg)) + '</pre></blockquote></qt>',
286 '&Yes', '&No')):
287 return
288 else:
289 try:
290 scm.doCommit(keepFiles, commitFiles, commitMsg)
291 except CommitError, e:
292 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
293 "Commit failed during " + e.operation + ": " + e.msg,
294 '&Ok')
295 except OSError, e:
296 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
297 "Commit failed: " + e.strerror,
298 '&Ok')
299 else:
300 self.cmitItem.file.textW.setText('')
301 self.refreshFiles()
303 def getFileState(self):
304 ret = FileState()
305 cur = self.filesW.currentItem()
306 if cur and cur != self.cmitItem:
307 ret.current = self.filesW.currentItem().file.srcName
308 else:
309 ret.current = None
310 ret.selected = {}
312 for x in self.filesW:
313 if x.isSelected():
314 ret.selected[x.file.srcName] = True
315 return ret
317 def restoreFileState(self, state):
318 for x in self.filesW:
319 if state.selected.has_key(x.file.srcName):
320 x.setSelected(True)
321 if x.file.srcName == state.current:
322 self.filesW.setCurrentItem(x)
324 def newTextEdit(self):
325 ret = qt.QTextEdit()
326 self.text.addWidget(ret)
327 return ret
329 def setFiles(self, files):
330 state = self.getFileState()
331 self.filesW.clear()
332 self.createCmitItem()
333 for f in self.files:
334 self.text.removeWidget(f.textW)
336 self.files = []
337 for f in files:
338 f.textW = self.newTextEdit()
339 f.textW.setReadOnly(False)
340 f.textW.setTextFormat(Qt.RichText)
341 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
342 self.files.append(f)
343 self.filesW.insertItem(MyListItem(self.filesW, f, f.updated))
345 self.filesW.setCurrentItem(self.cmitItem)
347 # For some reason the currentChanged signal isn't emitted
348 # here. We call currentChange ourselves instead.
349 self.currentChange(self.cmitItem)
351 self.restoreFileState(state)
353 def refreshFiles(self, ignored=None):
354 scm.updateCache()
355 self.setFiles(scm.getFiles())
357 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
358 def fixCommitMsgWhiteSpace(msg):
359 msg = msg.lstrip()
360 msg = msg.rstrip()
361 msg = re.sub(commitMsgRE, '\n', msg)
362 msg += '\n'
363 return msg
365 def formatPatchRichText(patch, colors):
366 ret = ['<qt><pre><font color="', colors['std'], '">']
367 prev = ' '
368 for l in patch.split('\n'):
369 if len(l) > 0:
370 c = l[0]
371 else:
372 c = ' '
374 if c != prev:
375 if c == '+': style = 'new'
376 elif c == '-': style = 'remove'
377 elif c == '@': style = 'head'
378 else: style = 'std'
379 ret.extend(['</font><font color="', colors[style], '">'])
380 prev = c
381 ret.extend([str(qt.QStyleSheet.escape(l)), '\n'])
382 ret.append('</pre></qt>')
383 return ''.join(ret)
385 class ProgramError(Exception):
386 def __init__(self, program, err):
387 self.program = program
388 self.error = err
390 def getEditor():
391 if os.environ.has_key('GCT_EDITOR'):
392 return os.environ['GCT_EDITOR']
393 elif os.environ.has_key('EDITOR'):
394 return os.environ['EDITOR']
395 else:
396 return None
398 def shellQuote(str):
399 res = ''
400 for c in str:
401 if c == '\\':
402 res += '\\'
403 elif c == "'":
404 res += "'\\''"
405 else:
406 res += c
407 return "'" + res + "'"
409 def runProgram(prog, input=None):
410 debug('runProgram prog: ' + str(prog) + " input: " + str(input))
411 if type(prog) is str:
412 progStr = prog
413 else:
414 progStr = ' '.join(prog)
416 try:
417 pop = subprocess.Popen(prog,
418 shell = type(prog) is str,
419 stderr=subprocess.STDOUT,
420 stdout=subprocess.PIPE,
421 stdin=subprocess.PIPE)
422 except OSError, e:
423 debug("strerror: " + e.strerror)
424 raise ProgramError(progStr, e.strerror)
426 if input != None:
427 pop.stdin.write(input)
428 pop.stdin.close()
430 out = pop.stdout.read()
431 code = pop.wait()
432 if code != 0:
433 debug("error output: " + out)
434 raise ProgramError(progStr, out)
435 debug("output: " + out.replace('\0', '\n'))
436 return out
438 scm.repoValid()
440 app = qt.QApplication(sys.argv)
441 settings = qt.QSettings()
442 settings.beginGroup('/' + shortName)
443 settings.beginGroup('/geometry/')
445 mw = MainWidget()
446 mw.refreshFiles()
448 mw.resize(settings.readNumEntry('width', 500)[0],
449 settings.readNumEntry('height', 600)[0])
452 # The following code doesn't work correctly in some (at least
453 # Metacity) window
454 # managers. http://doc.trolltech.com/3.3/geometry.html contains some
455 # information about this issue.
456 # mw.move(settings.readNumEntry('x', 100)[0],
457 # settings.readNumEntry('y', 100)[0])
459 mw.show()
460 app.setMainWidget(mw)
463 # Handle CTRL-C appropriately
464 signal.signal(signal.SIGINT, lambda s, f: app.quit())
466 ret = app.exec_loop()
467 del settings
468 sys.exit(ret)